- Remove dynamic fs/promises, os, and path imports from SubagentDetailBuilder - Add fsProvider and projectsDir parameters to buildSubagentDetail function - Replace fs.access() with fsProvider.exists() for file existence check - Replace os.homedir() path construction with projectsDir parameter - Update ChunkBuilder.buildSubagentDetail() to accept and pass new parameters - Update IPC subagents handler to obtain provider and projectsDir from ProjectScanner - Update initializeSubagentHandlers to accept ProjectScanner parameter - Update handlers.ts to pass ProjectScanner to subagent handler initialization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
/**
|
|
* ChunkBuilder service - Builds visualization chunks from parsed session data.
|
|
*
|
|
* Responsibilities:
|
|
* - Group messages into chunks (user message + responses)
|
|
* - Attach subagents to chunks
|
|
* - Build waterfall chart data
|
|
* - Calculate chunk metrics
|
|
*
|
|
* This module orchestrates chunk building using specialized modules:
|
|
* - MessageClassifier: Classify messages into categories
|
|
* - ChunkFactory: Create individual chunk objects
|
|
* - ProcessLinker: Link subagent processes to chunks
|
|
* - SemanticStepExtractor: Extract semantic steps from AI chunks
|
|
* - SemanticStepGrouper: Group semantic steps for UI
|
|
* - ToolExecutionBuilder: Build tool execution tracking
|
|
* - SubagentDetailBuilder: Build subagent drill-down details
|
|
* - ConversationGroupBuilder: Alternative grouping strategy
|
|
*/
|
|
|
|
import {
|
|
type Chunk,
|
|
type ConversationGroup,
|
|
EMPTY_METRICS,
|
|
type EnhancedChunk,
|
|
isAIChunk,
|
|
isCompactChunk,
|
|
isSystemChunk,
|
|
isUserChunk,
|
|
type MessageCategory,
|
|
type ParsedMessage,
|
|
type Process,
|
|
type Session,
|
|
type SessionDetail,
|
|
type SessionMetrics,
|
|
type SubagentDetail,
|
|
type TokenUsage,
|
|
} from '@main/types';
|
|
import { calculateMetrics } from '@main/utils/jsonl';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import type { WaterfallData, WaterfallItem } from '@shared/types';
|
|
|
|
const logger = createLogger('Service:ChunkBuilder');
|
|
|
|
import { classifyMessages } from '../parsing/MessageClassifier';
|
|
|
|
import {
|
|
buildAIChunkFromBuffer,
|
|
buildCompactChunk,
|
|
buildSystemChunk,
|
|
buildUserChunk,
|
|
} from './ChunkFactory';
|
|
import { buildGroups as buildConversationGroups } from './ConversationGroupBuilder';
|
|
import { buildSubagentDetail as buildSubagentDetailFn } from './SubagentDetailBuilder';
|
|
|
|
import type { SubagentResolver } from '../discovery/SubagentResolver';
|
|
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
|
|
import type { SessionParser } from '../parsing/SessionParser';
|
|
|
|
export class ChunkBuilder {
|
|
// ===========================================================================
|
|
// Chunk Building
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Build chunks from messages using 4-category classification.
|
|
* Produces independent UserChunks, AIChunks, and SystemChunks.
|
|
*
|
|
* Categories:
|
|
* - User: Genuine user input (creates UserChunk, renders RIGHT)
|
|
* - System: Command output <local-command-stdout> (creates SystemChunk, renders LEFT)
|
|
* - Hard Noise: Filtered out entirely (system metadata, caveats, reminders)
|
|
* - AI: All other messages grouped into AIChunks (renders LEFT)
|
|
*
|
|
* All chunk types are INDEPENDENT - no pairing between User and AI.
|
|
*/
|
|
buildChunks(messages: ParsedMessage[], subagents: Process[] = []): EnhancedChunk[] {
|
|
const chunks: EnhancedChunk[] = [];
|
|
|
|
// Filter to main thread messages (non-sidechain)
|
|
const mainMessages = messages.filter((m) => !m.isSidechain);
|
|
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
|
|
|
|
// Classify each message into categories using MessageClassifier
|
|
const classified = classifyMessages(mainMessages);
|
|
|
|
// Log classification summary
|
|
const categoryCounts = new Map<MessageCategory, number>();
|
|
for (const { category } of classified) {
|
|
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
|
}
|
|
logger.debug('Message classification:', Object.fromEntries(categoryCounts));
|
|
|
|
// Build chunks from classification - AI chunks are INDEPENDENT
|
|
let aiBuffer: ParsedMessage[] = [];
|
|
|
|
for (const { message, category } of classified) {
|
|
switch (category) {
|
|
case 'hardNoise':
|
|
// Skip - filtered out
|
|
break;
|
|
|
|
case 'compact':
|
|
// Flush any buffered AI messages first
|
|
if (aiBuffer.length > 0) {
|
|
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
|
|
aiBuffer = [];
|
|
}
|
|
chunks.push(buildCompactChunk(message));
|
|
break;
|
|
|
|
case 'user':
|
|
// Flush any buffered AI messages first
|
|
if (aiBuffer.length > 0) {
|
|
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
|
|
aiBuffer = [];
|
|
}
|
|
chunks.push(buildUserChunk(message));
|
|
break;
|
|
|
|
case 'system':
|
|
// Flush any buffered AI messages first
|
|
if (aiBuffer.length > 0) {
|
|
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
|
|
aiBuffer = [];
|
|
}
|
|
chunks.push(buildSystemChunk(message));
|
|
break;
|
|
|
|
case 'ai':
|
|
aiBuffer.push(message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Flush remaining AI buffer
|
|
if (aiBuffer.length > 0) {
|
|
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
|
|
}
|
|
|
|
// Log final chunk summary
|
|
const userChunkCount = chunks.filter(isUserChunk).length;
|
|
const aiChunkCount = chunks.filter(isAIChunk).length;
|
|
const systemChunkCount = chunks.filter(isSystemChunk).length;
|
|
const compactChunkCount = chunks.filter(isCompactChunk).length;
|
|
logger.debug(
|
|
`Created ${chunks.length} chunks: ${userChunkCount} user, ${aiChunkCount} AI, ${systemChunkCount} system, ${compactChunkCount} compact`
|
|
);
|
|
|
|
return chunks;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Simplified Grouping Strategy (delegates to ConversationGroupBuilder)
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Build conversation groups using simplified grouping strategy.
|
|
* Groups one user message with all AI responses until the next user message.
|
|
*
|
|
* This is a cleaner alternative to buildChunks() that:
|
|
* - Uses simpler time-based grouping
|
|
* - Separates Task executions from regular tool executions
|
|
* - Links subagents more explicitly via TaskExecution
|
|
*/
|
|
buildGroups(messages: ParsedMessage[], subagents: Process[]): ConversationGroup[] {
|
|
return buildConversationGroups(messages, subagents);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Session Detail Building
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Build a complete SessionDetail from parsed data.
|
|
*/
|
|
buildSessionDetail(
|
|
session: Session,
|
|
messages: ParsedMessage[],
|
|
subagents: Process[]
|
|
): SessionDetail {
|
|
// Build chunks
|
|
const chunks = this.buildChunks(messages, subagents);
|
|
|
|
// Calculate overall metrics
|
|
const metrics = calculateMetrics(messages);
|
|
|
|
return {
|
|
session,
|
|
messages,
|
|
chunks,
|
|
processes: subagents,
|
|
metrics,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build waterfall chart data from chunks and resolved processes.
|
|
*/
|
|
buildWaterfallData(chunks: Chunk[], processes: Process[]): WaterfallData {
|
|
const items: WaterfallItem[] = [];
|
|
|
|
for (const chunk of chunks) {
|
|
const baseChunkItem: WaterfallItem = {
|
|
id: chunk.id,
|
|
label: this.getChunkLabel(chunk),
|
|
startTime: chunk.startTime,
|
|
endTime: chunk.endTime,
|
|
durationMs: chunk.durationMs,
|
|
tokenUsage: this.toTokenUsage(chunk.metrics),
|
|
level: 0,
|
|
type: 'chunk',
|
|
isParallel: false,
|
|
};
|
|
items.push(baseChunkItem);
|
|
|
|
if (isAIChunk(chunk)) {
|
|
for (const toolExec of chunk.toolExecutions) {
|
|
const endTime = toolExec.endTime ?? toolExec.startTime;
|
|
items.push({
|
|
id: `tool-${toolExec.toolCall.id}`,
|
|
label: toolExec.toolCall.name,
|
|
startTime: toolExec.startTime,
|
|
endTime,
|
|
durationMs:
|
|
toolExec.durationMs ?? Math.max(endTime.getTime() - toolExec.startTime.getTime(), 0),
|
|
tokenUsage: {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
},
|
|
level: 1,
|
|
type: 'tool',
|
|
isParallel: false,
|
|
parentId: chunk.id,
|
|
});
|
|
}
|
|
|
|
for (const process of chunk.processes) {
|
|
items.push({
|
|
id: `subagent-${process.id}`,
|
|
label: process.description || process.subagentType || process.id,
|
|
startTime: process.startTime,
|
|
endTime: process.endTime,
|
|
durationMs: process.durationMs,
|
|
tokenUsage: this.toTokenUsage(process.metrics),
|
|
level: 1,
|
|
type: 'subagent',
|
|
isParallel: process.isParallel,
|
|
parentId: chunk.id,
|
|
metadata: {
|
|
subagentType: process.subagentType,
|
|
messageCount: process.messages.length,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add any process that was not attached to an AI chunk (defensive fallback)
|
|
for (const process of processes) {
|
|
const itemId = `subagent-${process.id}`;
|
|
if (items.some((item) => item.id === itemId)) {
|
|
continue;
|
|
}
|
|
items.push({
|
|
id: itemId,
|
|
label: process.description || process.subagentType || process.id,
|
|
startTime: process.startTime,
|
|
endTime: process.endTime,
|
|
durationMs: process.durationMs,
|
|
tokenUsage: this.toTokenUsage(process.metrics),
|
|
level: 0,
|
|
type: 'subagent',
|
|
isParallel: process.isParallel,
|
|
metadata: {
|
|
subagentType: process.subagentType,
|
|
messageCount: process.messages.length,
|
|
},
|
|
});
|
|
}
|
|
|
|
const sortedItems = [...items];
|
|
sortedItems.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
|
|
|
if (sortedItems.length === 0) {
|
|
const now = new Date();
|
|
return {
|
|
items: [],
|
|
minTime: now,
|
|
maxTime: now,
|
|
totalDurationMs: 0,
|
|
};
|
|
}
|
|
|
|
const minTime = sortedItems.reduce(
|
|
(min, item) => (item.startTime.getTime() < min.getTime() ? item.startTime : min),
|
|
sortedItems[0].startTime
|
|
);
|
|
const maxTime = sortedItems.reduce(
|
|
(max, item) => (item.endTime.getTime() > max.getTime() ? item.endTime : max),
|
|
sortedItems[0].endTime
|
|
);
|
|
|
|
return {
|
|
items: sortedItems,
|
|
minTime,
|
|
maxTime,
|
|
totalDurationMs: Math.max(maxTime.getTime() - minTime.getTime(), 0),
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Utility Methods
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get total metrics for all chunks.
|
|
*/
|
|
getTotalChunkMetrics(chunks: (Chunk | EnhancedChunk)[]): SessionMetrics {
|
|
if (chunks.length === 0) {
|
|
return { ...EMPTY_METRICS };
|
|
}
|
|
|
|
let durationMs = 0;
|
|
let inputTokens = 0;
|
|
let outputTokens = 0;
|
|
let cacheReadTokens = 0;
|
|
let cacheCreationTokens = 0;
|
|
let messageCount = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
durationMs += chunk.durationMs;
|
|
inputTokens += chunk.metrics.inputTokens;
|
|
outputTokens += chunk.metrics.outputTokens;
|
|
cacheReadTokens += chunk.metrics.cacheReadTokens;
|
|
cacheCreationTokens += chunk.metrics.cacheCreationTokens;
|
|
messageCount += chunk.metrics.messageCount;
|
|
}
|
|
|
|
return {
|
|
durationMs,
|
|
totalTokens: inputTokens + outputTokens,
|
|
inputTokens,
|
|
outputTokens,
|
|
cacheReadTokens,
|
|
cacheCreationTokens,
|
|
messageCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find chunk containing a specific message UUID.
|
|
*/
|
|
findChunkByMessageId(
|
|
chunks: (Chunk | EnhancedChunk)[],
|
|
messageUuid: string
|
|
): Chunk | EnhancedChunk | undefined {
|
|
return chunks.find((c) => {
|
|
// UserChunk: check userMessage
|
|
if (isUserChunk(c)) {
|
|
return c.userMessage.uuid === messageUuid;
|
|
}
|
|
// AIChunk: check responses
|
|
if (isAIChunk(c)) {
|
|
return c.responses.some((r) => r.uuid === messageUuid);
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find chunk containing a specific subagent.
|
|
* Only AIChunks have processes.
|
|
*/
|
|
findChunkBySubagentId(
|
|
chunks: (Chunk | EnhancedChunk)[],
|
|
subagentId: string
|
|
): Chunk | EnhancedChunk | undefined {
|
|
return chunks.find((c) => {
|
|
if (isAIChunk(c)) {
|
|
return c.processes.some((s: Process) => s.id === subagentId);
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
private getChunkLabel(chunk: Chunk): string {
|
|
switch (chunk.chunkType) {
|
|
case 'user':
|
|
return 'User';
|
|
case 'ai':
|
|
return 'Assistant';
|
|
case 'system':
|
|
return 'System';
|
|
case 'compact':
|
|
return 'Compact';
|
|
default:
|
|
return 'Chunk';
|
|
}
|
|
}
|
|
|
|
private toTokenUsage(metrics: SessionMetrics): TokenUsage {
|
|
return {
|
|
input_tokens: metrics.inputTokens,
|
|
output_tokens: metrics.outputTokens,
|
|
cache_read_input_tokens: metrics.cacheReadTokens || undefined,
|
|
cache_creation_input_tokens: metrics.cacheCreationTokens || undefined,
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Subagent Detail Building (for drill-down)
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Build detailed information for a specific subagent.
|
|
* Used for drill-down modal to show subagent's internal execution.
|
|
*
|
|
* @param projectId - Project ID
|
|
* @param sessionId - Parent session ID (currently unused, kept for API consistency)
|
|
* @param subagentId - Subagent ID to load
|
|
* @param sessionParser - SessionParser instance for parsing subagent file
|
|
* @param subagentResolver - SubagentResolver instance for nested subagents
|
|
* @returns SubagentDetail or null if not found
|
|
*/
|
|
async buildSubagentDetail(
|
|
projectId: string,
|
|
sessionId: string,
|
|
subagentId: string,
|
|
sessionParser: SessionParser,
|
|
subagentResolver: SubagentResolver,
|
|
fsProvider: FileSystemProvider,
|
|
projectsDir: string
|
|
): Promise<SubagentDetail | null> {
|
|
// Delegate to the extracted module, passing buildChunks as a callback
|
|
return buildSubagentDetailFn(
|
|
projectId,
|
|
sessionId,
|
|
subagentId,
|
|
sessionParser,
|
|
subagentResolver,
|
|
(messages, subagents) => this.buildChunks(messages, subagents),
|
|
fsProvider,
|
|
projectsDir
|
|
);
|
|
}
|
|
}
|