agent-ecosystem/src/main/services/analysis/ChunkBuilder.ts
iliya 7b13a4c398 refactor: update README and enhance team UI components
- Changed image format for agent comments screenshot in README.
- Improved table of contents in README for better navigation.
- Refactored imports in IPC config and discovery services for consistency.
- Added shimmer effect for waiting members in CSS.
- Enhanced dashboard view to display active teams with online indicators.
- Updated team provisioning components to support message severity.
- Improved task detail dialog layout for related tasks and dependencies.
- Adjusted team model selector default value and refined member status handling.
- Fixed minor styling issues in messages panel and tab bar.

These changes aim to improve user experience and maintainability across the application.
2026-03-23 12:58:38 +02:00

458 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 { startMainSpan } from '../../sentry';
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[] = [],
options?: { includeSidechain?: boolean }
): EnhancedChunk[] {
return startMainSpan('chunks.build', 'build', () => {
const chunks: EnhancedChunk[] = [];
// Filter to main thread messages (non-sidechain)
const mainMessages = options?.includeSidechain
? messages
: 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;
}); // startMainSpan
}
// ===========================================================================
// 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, { includeSidechain: true }),
fsProvider,
projectsDir
);
}
}