- Updated error handling in both registerUtilityRoutes and handleReadMentionedFile functions to return null for expected ENOENT errors, indicating that the file simply does not exist. This improves robustness in scenarios with stale or misdetected file references. - Enhanced the isValidFileRef function to require either 3+ segments or a file extension to avoid false positives for npm scoped packages, ensuring more accurate file reference validation.
706 lines
20 KiB
TypeScript
706 lines
20 KiB
TypeScript
/**
|
|
* Transforms EnhancedChunk[] into SessionConversation structure.
|
|
*
|
|
* This module converts chunk-based data into a flat list of ChatItems
|
|
* (UserGroups, SystemGroups, AIGroups) for a chat-style display.
|
|
* Each item is independent - no pairing between user and AI chunks.
|
|
*/
|
|
|
|
import {
|
|
isAssistantMessage,
|
|
isEnhancedAIChunk,
|
|
isEnhancedCompactChunk,
|
|
isEnhancedSystemChunk,
|
|
isEnhancedUserChunk,
|
|
} from '@renderer/types/data';
|
|
import {
|
|
getFirstSegment,
|
|
hasPathSeparator,
|
|
isRelativePath,
|
|
splitPathSegments,
|
|
} from '@renderer/utils/pathUtils';
|
|
import { isCommandContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import type {
|
|
EnhancedAIChunk,
|
|
EnhancedChunk,
|
|
EnhancedCompactChunk,
|
|
EnhancedSystemChunk,
|
|
EnhancedUserChunk,
|
|
ParsedMessage,
|
|
Process,
|
|
SemanticStep,
|
|
} from '@renderer/types/data';
|
|
import type {
|
|
AIGroup,
|
|
AIGroupStatus,
|
|
AIGroupSummary,
|
|
AIGroupTokens,
|
|
ChatItem,
|
|
CommandInfo,
|
|
CompactGroup,
|
|
FileReference,
|
|
ImageData,
|
|
SessionConversation,
|
|
SystemGroup,
|
|
UserGroup,
|
|
UserGroupContent,
|
|
} from '@renderer/types/groups';
|
|
|
|
const logger = createLogger('Util:groupTransformer');
|
|
|
|
// =============================================================================
|
|
// Constants
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Regex pattern for detecting slash commands.
|
|
* Matches: /command-name [optional args]
|
|
* Uses non-greedy matching and limited repetition to prevent ReDoS.
|
|
*/
|
|
// eslint-disable-next-line security/detect-unsafe-regex -- Pattern is safe: limited to 1000 chars and used on bounded user input
|
|
const COMMAND_PATTERN = /\/([a-z][a-z-]{0,50})(?:\s+(\S[^\n]{0,1000}))?$/gim;
|
|
|
|
/**
|
|
* Maximum characters to extract for thinking preview.
|
|
*/
|
|
const THINKING_PREVIEW_LENGTH = 100;
|
|
|
|
// =============================================================================
|
|
// Main Transformation Function
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Transforms EnhancedChunk[] into SessionConversation.
|
|
*
|
|
* Produces a flat list of independent ChatItems (user, system, AI).
|
|
* Each chunk type becomes its own item - no pairing or grouping.
|
|
*
|
|
* @param chunks - Array of enhanced chunks with semantic steps
|
|
* @param _subagents - Array of all subagents in the session (unused, processes come from chunks)
|
|
* @param isOngoing - Whether the session is still in progress (marks last AI group)
|
|
* @returns SessionConversation structure for chat-style rendering
|
|
*/
|
|
export function transformChunksToConversation(
|
|
chunks: EnhancedChunk[],
|
|
_subagents: Process[],
|
|
isOngoing: boolean = false
|
|
): SessionConversation {
|
|
if (!chunks || chunks.length === 0) {
|
|
return {
|
|
sessionId: '',
|
|
items: [],
|
|
totalUserGroups: 0,
|
|
totalSystemGroups: 0,
|
|
totalAIGroups: 0,
|
|
totalCompactGroups: 0,
|
|
};
|
|
}
|
|
|
|
const items: ChatItem[] = [];
|
|
let userCount = 0;
|
|
let systemCount = 0;
|
|
let aiCount = 0;
|
|
let compactCount = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
if (isEnhancedUserChunk(chunk)) {
|
|
items.push({
|
|
type: 'user',
|
|
group: createUserGroupFromChunk(chunk, userCount++),
|
|
});
|
|
} else if (isEnhancedSystemChunk(chunk)) {
|
|
items.push({
|
|
type: 'system',
|
|
group: createSystemGroup(chunk),
|
|
});
|
|
systemCount++;
|
|
} else if (isEnhancedAIChunk(chunk)) {
|
|
items.push({
|
|
type: 'ai',
|
|
group: createAIGroupFromChunk(chunk, aiCount),
|
|
});
|
|
aiCount++;
|
|
} else if (isEnhancedCompactChunk(chunk)) {
|
|
items.push({
|
|
type: 'compact',
|
|
group: createCompactGroup(chunk),
|
|
});
|
|
compactCount++;
|
|
} else {
|
|
const unhandledChunkType =
|
|
'chunkType' in chunk ? (chunk as EnhancedChunk).chunkType : 'unknown';
|
|
logger.warn('Unhandled chunk type:', unhandledChunkType);
|
|
}
|
|
}
|
|
|
|
// Post-pass: enrich CompactGroups with token deltas
|
|
let phaseCounter = 1;
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].type === 'compact') {
|
|
phaseCounter++;
|
|
const compactItem = items[i] as { type: 'compact'; group: CompactGroup };
|
|
compactItem.group.startingPhaseNumber = phaseCounter;
|
|
|
|
// Find last AI group before and first AI group after
|
|
const preAi = findLastAiBefore(items, i);
|
|
const postAi = findFirstAiAfter(items, i);
|
|
if (preAi && postAi) {
|
|
const pre = getLastAssistantTotalTokens(preAi);
|
|
// Use FIRST assistant message after compaction — it reflects the actual
|
|
// compacted context size before the AI generates more content.
|
|
const post = getFirstAssistantTotalTokens(postAi);
|
|
if (pre !== undefined && post !== undefined) {
|
|
compactItem.group.tokenDelta = {
|
|
preCompactionTokens: pre,
|
|
postCompactionTokens: post,
|
|
delta: post - pre,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If session is ongoing, mark the last AI group (but don't override interrupted status)
|
|
if (isOngoing && aiCount > 0) {
|
|
// Find the last AI item and mark it as ongoing
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const item = items[i];
|
|
if (item.type === 'ai') {
|
|
const currentStatus = item.group.status;
|
|
// Don't override 'interrupted' status - interruption takes precedence over ongoing
|
|
if (currentStatus !== 'interrupted') {
|
|
(item.group as AIGroup & { isOngoing?: boolean }).isOngoing = true;
|
|
(item.group as AIGroup & { status?: AIGroupStatus }).status = 'in_progress';
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionId: chunks[0]?.id ?? 'unknown',
|
|
items,
|
|
totalUserGroups: userCount,
|
|
totalSystemGroups: systemCount,
|
|
totalAIGroups: aiCount,
|
|
totalCompactGroups: compactCount,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// UserGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a UserGroup from an EnhancedUserChunk.
|
|
*
|
|
* @param chunk - The user chunk to transform
|
|
* @param index - Index within the session (for ordering)
|
|
* @returns UserGroup with parsed content
|
|
*/
|
|
function createUserGroupFromChunk(chunk: EnhancedUserChunk, index: number): UserGroup {
|
|
return createUserGroup(chunk.userMessage, index);
|
|
}
|
|
|
|
/**
|
|
* Creates a UserGroup from a ParsedMessage.
|
|
*
|
|
* @param message - The user's input message
|
|
* @param index - Index within the session (for ordering)
|
|
* @returns UserGroup with parsed content
|
|
*/
|
|
function createUserGroup(message: ParsedMessage, index: number): UserGroup {
|
|
const content = extractUserGroupContent(message);
|
|
|
|
return {
|
|
id: `user-${message.uuid}`,
|
|
message,
|
|
timestamp: message.timestamp,
|
|
content,
|
|
index,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extracts and parses content from a user message.
|
|
*
|
|
* @param message - The user message to parse
|
|
* @returns Parsed UserGroupContent
|
|
*/
|
|
function extractUserGroupContent(message: ParsedMessage): UserGroupContent {
|
|
let rawText = '';
|
|
const images: ImageData[] = [];
|
|
const fileReferences: FileReference[] = [];
|
|
|
|
// Extract text from content
|
|
// Note: Image handling not yet implemented - images are not part of ContentBlock type
|
|
if (typeof message.content === 'string') {
|
|
rawText = message.content;
|
|
} else if (Array.isArray(message.content)) {
|
|
for (const block of message.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
rawText += block.text;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize content for display (handles XML tags from command messages)
|
|
// This converts <command-name>/model</command-name> to "/model"
|
|
const sanitizedText = sanitizeDisplayContent(rawText);
|
|
|
|
// Check if this is a command message (for special handling)
|
|
const isCommand = isCommandContent(rawText);
|
|
|
|
// Extract commands from the sanitized text (for inline /commands in regular messages)
|
|
// For command messages, the command is already extracted as sanitizedText
|
|
const commands = isCommand ? [] : extractCommands(sanitizedText);
|
|
|
|
// Extract file references (@file.ts) from sanitized text
|
|
fileReferences.push(...extractFileReferences(sanitizedText));
|
|
|
|
// For command messages, use the sanitized command as display text
|
|
// For regular messages, remove inline commands from display
|
|
let displayText = sanitizedText;
|
|
if (!isCommand) {
|
|
for (const cmd of commands) {
|
|
displayText = displayText.replace(cmd.raw, '').trim();
|
|
}
|
|
}
|
|
|
|
return {
|
|
text: displayText || undefined,
|
|
rawText: sanitizedText, // Use sanitized version as rawText for display
|
|
commands,
|
|
images,
|
|
fileReferences,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extracts commands from text using regex.
|
|
*
|
|
* @param text - Text to parse for commands
|
|
* @returns Array of CommandInfo objects
|
|
*/
|
|
function extractCommands(text: string): CommandInfo[] {
|
|
if (!text) return [];
|
|
|
|
const commands: CommandInfo[] = [];
|
|
let match: RegExpExecArray | null;
|
|
|
|
// Reset regex state
|
|
COMMAND_PATTERN.lastIndex = 0;
|
|
|
|
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
|
|
const [fullMatch, commandName, args] = match;
|
|
commands.push({
|
|
name: commandName,
|
|
args: args?.trim(),
|
|
raw: fullMatch,
|
|
startIndex: match.index,
|
|
endIndex: match.index + fullMatch.length,
|
|
});
|
|
}
|
|
|
|
return commands;
|
|
}
|
|
|
|
/**
|
|
* Known directory prefixes that identify file references.
|
|
*/
|
|
const KNOWN_DIRS = new Set([
|
|
'src',
|
|
'apps',
|
|
'app',
|
|
'lib',
|
|
'types',
|
|
'packages',
|
|
'components',
|
|
'utils',
|
|
'services',
|
|
'hooks',
|
|
'store',
|
|
'public',
|
|
'assets',
|
|
'config',
|
|
'tests',
|
|
'test',
|
|
'specs',
|
|
'spec',
|
|
'e2e',
|
|
'docs',
|
|
'scripts',
|
|
'screens',
|
|
'features',
|
|
'pages',
|
|
'views',
|
|
'models',
|
|
'controllers',
|
|
'routes',
|
|
'middleware',
|
|
'api',
|
|
'common',
|
|
'core',
|
|
'modules',
|
|
'client',
|
|
'server',
|
|
'web',
|
|
'mobile',
|
|
'native',
|
|
'electron',
|
|
'node_modules',
|
|
]);
|
|
|
|
/**
|
|
* Simple pattern for detecting @ mentions that could be file paths.
|
|
* The filtering logic in extractFileReferences determines validity.
|
|
*/
|
|
const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g;
|
|
|
|
/**
|
|
* Checks if a path looks like a valid file reference.
|
|
* Must start with known dir, contain /, or start with ./ or ../
|
|
*/
|
|
function isValidFileRef(path: string): boolean {
|
|
// Check for relative path indicators
|
|
if (isRelativePath(path)) {
|
|
return true;
|
|
}
|
|
// Check if starts with known directory
|
|
const first = getFirstSegment(path);
|
|
if (KNOWN_DIRS.has(first)) {
|
|
return true;
|
|
}
|
|
// Check if contains a path separator (indicates directory structure)
|
|
// Require either 3+ segments or a file extension to avoid matching
|
|
// npm scoped packages like @scope/name (extracted as "scope/name")
|
|
if (hasPathSeparator(path) && path.length > 2) {
|
|
const segments = splitPathSegments(path);
|
|
if (segments.length > 2 || /\.[a-zA-Z0-9]+$/.test(path)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extracts file references (@file.ts) from text.
|
|
*
|
|
* @param text - Text to parse for file references
|
|
* @returns Array of FileReference objects
|
|
*/
|
|
export function extractFileReferences(text: string): FileReference[] {
|
|
if (!text) return [];
|
|
|
|
const references: FileReference[] = [];
|
|
// Reset regex state before use
|
|
FILE_REF_PATTERN.lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = FILE_REF_PATTERN.exec(text)) !== null) {
|
|
const [fullMatch, path] = match;
|
|
// Only include if it looks like a valid file reference
|
|
if (isValidFileRef(path)) {
|
|
references.push({
|
|
path,
|
|
raw: fullMatch,
|
|
});
|
|
}
|
|
}
|
|
|
|
return references;
|
|
}
|
|
|
|
// =============================================================================
|
|
// SystemGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a SystemGroup from an EnhancedSystemChunk.
|
|
*
|
|
* @param chunk - The system chunk to transform
|
|
* @returns SystemGroup with command output
|
|
*/
|
|
function createSystemGroup(chunk: EnhancedSystemChunk): SystemGroup {
|
|
return {
|
|
id: chunk.id, // Use stable chunk ID instead of array index
|
|
message: chunk.message,
|
|
timestamp: chunk.startTime,
|
|
commandOutput: chunk.commandOutput,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// CompactGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a CompactGroup from an EnhancedCompactChunk.
|
|
*
|
|
* @param chunk - The compact chunk to transform
|
|
* @returns CompactGroup marking where conversation was compacted, with message content
|
|
*/
|
|
function createCompactGroup(chunk: EnhancedCompactChunk): CompactGroup {
|
|
return {
|
|
id: chunk.id, // Use stable chunk ID instead of array index
|
|
timestamp: chunk.startTime,
|
|
message: chunk.message, // Pass through the compact summary message
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// AIGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates an AIGroup from an EnhancedAIChunk.
|
|
*
|
|
* @param chunk - The AI chunk to transform
|
|
* @param turnIndex - 0-based index of this AI group within the session
|
|
* @returns AIGroup with semantic steps and metrics
|
|
*/
|
|
function createAIGroupFromChunk(chunk: EnhancedAIChunk, turnIndex: number): AIGroup {
|
|
const steps = chunk.semanticSteps;
|
|
|
|
// Calculate timing from all steps
|
|
const startTime = steps.length > 0 ? steps[0].startTime : chunk.startTime;
|
|
const endTime =
|
|
steps.length > 0
|
|
? (steps[steps.length - 1].endTime ?? steps[steps.length - 1].startTime)
|
|
: chunk.endTime;
|
|
const durationMs = endTime.getTime() - startTime.getTime();
|
|
|
|
// Find any source assistant message for token calculation
|
|
const sourceMessage = chunk.responses.find((msg) => isAssistantMessage(msg)) ?? null;
|
|
|
|
// Calculate tokens from all steps
|
|
const tokens = calculateTokensFromSteps(steps, sourceMessage);
|
|
|
|
// Generate summary from all steps
|
|
const summary = computeAIGroupSummary(steps);
|
|
|
|
// Determine status from all steps
|
|
const status = determineAIGroupStatus(steps);
|
|
|
|
return {
|
|
id: chunk.id, // Use stable chunk ID instead of array index
|
|
turnIndex,
|
|
startTime,
|
|
endTime,
|
|
durationMs,
|
|
steps,
|
|
tokens,
|
|
summary,
|
|
status,
|
|
processes: chunk.processes,
|
|
chunkId: chunk.id,
|
|
metrics: chunk.metrics,
|
|
responses: chunk.responses,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculates token metrics from semantic steps and source message.
|
|
*
|
|
* @param steps - Semantic steps in this AI Group
|
|
* @param sourceMessage - Source assistant message (if available)
|
|
* @returns Token metrics
|
|
*/
|
|
function calculateTokensFromSteps(
|
|
steps: SemanticStep[],
|
|
sourceMessage: ParsedMessage | null | undefined
|
|
): AIGroupTokens {
|
|
let input = 0;
|
|
let output = 0;
|
|
let cached = 0;
|
|
let thinking = 0;
|
|
|
|
// Sum from steps
|
|
for (const step of steps) {
|
|
if (step.tokens) {
|
|
input += step.tokens.input ?? 0;
|
|
output += step.tokens.output ?? 0;
|
|
cached += step.tokens.cached ?? 0;
|
|
}
|
|
if (step.tokenBreakdown) {
|
|
input += step.tokenBreakdown.input ?? 0;
|
|
output += step.tokenBreakdown.output ?? 0;
|
|
cached += step.tokenBreakdown.cacheRead ?? 0;
|
|
}
|
|
if (step.type === 'thinking' && step.tokens?.output) {
|
|
thinking += step.tokens.output;
|
|
}
|
|
}
|
|
|
|
// Override with source message usage if available (more accurate)
|
|
if (sourceMessage?.usage) {
|
|
input = sourceMessage.usage.input_tokens ?? 0;
|
|
output = sourceMessage.usage.output_tokens ?? 0;
|
|
cached = sourceMessage.usage.cache_read_input_tokens ?? 0;
|
|
}
|
|
|
|
return {
|
|
input,
|
|
output,
|
|
cached,
|
|
thinking,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// AIGroup Summary & Status Computation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Computes summary statistics for an AIGroup's collapsed view.
|
|
*
|
|
* @param steps - Semantic steps in the AI Group
|
|
* @returns Summary statistics
|
|
*/
|
|
function computeAIGroupSummary(steps: SemanticStep[]): AIGroupSummary {
|
|
let thinkingPreview: string | undefined;
|
|
let toolCallCount = 0;
|
|
let outputMessageCount = 0;
|
|
let subagentCount = 0;
|
|
let totalDurationMs = 0;
|
|
let totalTokens = 0;
|
|
let outputTokens = 0;
|
|
let cachedTokens = 0;
|
|
|
|
for (const step of steps) {
|
|
// Extract thinking preview from first thinking step
|
|
if (!thinkingPreview && step.type === 'thinking' && step.content.thinkingText) {
|
|
const fullText = step.content.thinkingText;
|
|
thinkingPreview =
|
|
fullText.length > THINKING_PREVIEW_LENGTH
|
|
? fullText.slice(0, THINKING_PREVIEW_LENGTH) + '...'
|
|
: fullText;
|
|
}
|
|
|
|
// Count step types
|
|
if (step.type === 'tool_call') toolCallCount++;
|
|
if (step.type === 'output') outputMessageCount++;
|
|
if (step.type === 'subagent') subagentCount++;
|
|
|
|
// Sum duration
|
|
totalDurationMs += step.durationMs ?? 0;
|
|
|
|
// Sum tokens
|
|
if (step.tokens) {
|
|
totalTokens += (step.tokens.input ?? 0) + (step.tokens.output ?? 0);
|
|
outputTokens += step.tokens.output ?? 0;
|
|
cachedTokens += step.tokens.cached ?? 0;
|
|
}
|
|
if (step.tokenBreakdown) {
|
|
totalTokens += step.tokenBreakdown.input + step.tokenBreakdown.output;
|
|
outputTokens += step.tokenBreakdown.output;
|
|
cachedTokens += step.tokenBreakdown.cacheRead;
|
|
}
|
|
}
|
|
|
|
return {
|
|
thinkingPreview,
|
|
toolCallCount,
|
|
outputMessageCount,
|
|
subagentCount,
|
|
totalDurationMs,
|
|
totalTokens,
|
|
outputTokens,
|
|
cachedTokens,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Determines the status of an AIGroup based on its steps.
|
|
*
|
|
* @param steps - Semantic steps in the AI Group
|
|
* @returns AIGroupStatus
|
|
*/
|
|
function determineAIGroupStatus(steps: SemanticStep[]): AIGroupStatus {
|
|
if (steps.length === 0) return 'error';
|
|
|
|
// Check for interruption
|
|
const hasInterruption = steps.some((step) => step.type === 'interruption');
|
|
if (hasInterruption) return 'interrupted';
|
|
|
|
// Check for errors
|
|
const hasError = steps.some((step) => step.type === 'tool_result' && step.content.isError);
|
|
if (hasError) return 'error';
|
|
|
|
// Check if any step is incomplete (no endTime)
|
|
const hasIncomplete = steps.some((step) => !step.endTime);
|
|
if (hasIncomplete) return 'in_progress';
|
|
|
|
// Otherwise, complete
|
|
return 'complete';
|
|
}
|
|
|
|
// =============================================================================
|
|
// CompactGroup Enrichment Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Find the last AI group before a given index in the items array.
|
|
*/
|
|
function findLastAiBefore(items: ChatItem[], index: number): AIGroup | null {
|
|
for (let i = index - 1; i >= 0; i--) {
|
|
if (items[i].type === 'ai') return items[i].group as AIGroup;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find the first AI group after a given index in the items array.
|
|
*/
|
|
function findFirstAiAfter(items: ChatItem[], index: number): AIGroup | null {
|
|
for (let i = index + 1; i < items.length; i++) {
|
|
if (items[i].type === 'ai') return items[i].group as AIGroup;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get total tokens from the last assistant message in an AI group.
|
|
* Sums input_tokens, output_tokens, cache_read_input_tokens, and cache_creation_input_tokens.
|
|
*/
|
|
function getLastAssistantTotalTokens(aiGroup: AIGroup): number | undefined {
|
|
const responses = aiGroup.responses || [];
|
|
for (let i = responses.length - 1; i >= 0; i--) {
|
|
const msg = responses[i];
|
|
if (msg.type === 'assistant' && msg.usage) {
|
|
return (
|
|
(msg.usage.input_tokens ?? 0) +
|
|
(msg.usage.output_tokens ?? 0) +
|
|
(msg.usage.cache_read_input_tokens ?? 0) +
|
|
(msg.usage.cache_creation_input_tokens ?? 0)
|
|
);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Get total tokens from the FIRST assistant message in an AI group.
|
|
* Used for post-compaction token measurement: the first response after compaction
|
|
* reflects the actual compacted context size before the AI generates more content.
|
|
*/
|
|
function getFirstAssistantTotalTokens(aiGroup: AIGroup): number | undefined {
|
|
const responses = aiGroup.responses || [];
|
|
for (const msg of responses) {
|
|
if (msg.type === 'assistant' && msg.usage) {
|
|
return (
|
|
(msg.usage.input_tokens ?? 0) +
|
|
(msg.usage.output_tokens ?? 0) +
|
|
(msg.usage.cache_read_input_tokens ?? 0) +
|
|
(msg.usage.cache_creation_input_tokens ?? 0)
|
|
);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper Functions
|
|
// =============================================================================
|