/** * ErrorTriggerChecker service - Checks different trigger types against messages. * * Provides utilities for: * - Checking tool_result triggers * - Checking tool_use triggers * - Checking token threshold triggers * - Validating project scope */ import { type ParsedMessage } from '@main/types'; import { extractProjectName } from '@main/utils/pathDecoder'; import { estimateTokens, extractToolResults, type ToolResultInfo, type ToolUseInfo, } from '../analysis/ToolResultExtractor'; import { formatTokens, getToolSummary } from '../analysis/ToolSummaryFormatter'; import { projectPathResolver } from '../discovery/ProjectPathResolver'; import { type NotificationTrigger } from '../infrastructure/ConfigManager'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; import { createDetectedError, type DetectedError, extractErrorMessage, findToolNameByToolUseId, } from './ErrorMessageBuilder'; import { extractToolUseField, getContentBlocks, matchesIgnorePatterns, matchesPattern, } from './TriggerMatcher'; // ============================================================================= // Repository Scope Checking // ============================================================================= // Cache for projectId -> repositoryId mapping to avoid repeated resolution const repositoryIdCache = new Map(); interface RepositoryScopeTarget { projectId: string; cwdHint?: string; } /** * Resolves a projectId to its repositoryId using GitIdentityResolver. * Results are cached for performance. * @param projectId - The encoded project ID (e.g., "-Users-username-myproject") * @returns Repository ID or null if not resolvable */ async function resolveRepositoryId(target: string | RepositoryScopeTarget): Promise { const projectId = typeof target === 'string' ? target : target.projectId; const cwdHint = typeof target === 'string' ? undefined : target.cwdHint; // Check cache first if (repositoryIdCache.has(projectId)) { return repositoryIdCache.get(projectId) ?? null; } const projectPath = await projectPathResolver.resolveProjectPath(projectId, { cwdHint }); // Resolve repository identity const identity = await gitIdentityResolver.resolveIdentity(projectPath); const repositoryId = identity?.id ?? null; // Cache the result repositoryIdCache.set(projectId, repositoryId); return repositoryId; } /** * Synchronous version of resolveRepositoryId using cached values only. * If not cached, attempts synchronous resolution via path heuristics. */ function resolveRepositoryIdSync(projectId: string): string | null { // Check cache first if (repositoryIdCache.has(projectId)) { return repositoryIdCache.get(projectId) ?? null; } // For sync context, we can't do async resolution // The async version should be called during initialization return null; } /** * Checks if the project matches the trigger's repository scope. * @param projectId - The encoded project ID (e.g., "-Users-username-myproject") * @param repositoryIds - Optional list of repository group IDs to scope the trigger to * @returns true if trigger should apply, false if it should be skipped */ export function matchesRepositoryScope(projectId: string, repositoryIds?: string[]): boolean { // If no repository IDs specified, trigger applies to all repositories if (!repositoryIds || repositoryIds.length === 0) { return true; } // Get the repository ID for this project (from cache) const repositoryId = resolveRepositoryIdSync(projectId); // If we can't resolve the repository ID, don't match if (!repositoryId) { return false; } // Check if the repository ID matches any of the configured IDs return repositoryIds.includes(repositoryId); } /** * Pre-resolves repository IDs for a list of project IDs. * Call this before checking triggers to populate the cache. */ export async function preResolveRepositoryIds( targets: (string | RepositoryScopeTarget)[] ): Promise { const uniqueTargets = new Map(); for (const target of targets) { if (typeof target === 'string') { if (!uniqueTargets.has(target)) { uniqueTargets.set(target, { projectId: target }); } continue; } const existing = uniqueTargets.get(target.projectId); if (!existing) { uniqueTargets.set(target.projectId, target); continue; } // Prefer a target with cwd hint if one was provided. if (!existing.cwdHint && target.cwdHint) { uniqueTargets.set(target.projectId, target); } } await Promise.all( Array.from(uniqueTargets.values()).map((target) => resolveRepositoryId(target)) ); } // ============================================================================= // Tool Result Trigger Checking // ============================================================================= /** * Checks if a tool_result matches a trigger. */ export function checkToolResultTrigger( message: ParsedMessage, trigger: NotificationTrigger, toolUseMap: Map, sessionId: string, projectId: string, filePath: string, lineNumber: number ): DetectedError | null { const toolResults = extractToolResults(message, findToolNameByToolUseId); for (const result of toolResults) { // If requireError is true, only match when is_error is true if (trigger.requireError) { if (!result.isError) { continue; } // Extract error message for ignore pattern checking const errorMessage = extractErrorMessage(result); // Check ignore patterns - if any match, skip this error if (matchesIgnorePatterns(errorMessage, trigger.ignorePatterns)) { continue; } // Create detected error return createDetectedError({ sessionId, projectId, filePath, projectName: extractProjectName(projectId), lineNumber, source: result.toolName ?? 'tool_result', message: errorMessage, timestamp: message.timestamp, cwd: message.cwd, toolUseId: result.toolUseId, triggerColor: trigger.color, triggerId: trigger.id, triggerName: trigger.name, }); } // Non-error tool_result triggers (if toolName is specified) if (trigger.toolName) { const toolUse = toolUseMap.get(result.toolUseId); if (toolUse?.name !== trigger.toolName) { continue; } // Match against content if matchField is 'content' if (trigger.matchField === 'content' && trigger.matchPattern) { const content = typeof result.content === 'string' ? result.content : JSON.stringify(result.content); if (!matchesPattern(content, trigger.matchPattern)) { continue; } if (matchesIgnorePatterns(content, trigger.ignorePatterns)) { continue; } return createDetectedError({ sessionId, projectId, filePath, projectName: extractProjectName(projectId), lineNumber, source: trigger.toolName, message: `Tool result matched: ${content.slice(0, 200)}`, timestamp: message.timestamp, cwd: message.cwd, toolUseId: result.toolUseId, triggerColor: trigger.color, triggerId: trigger.id, triggerName: trigger.name, }); } } } return null; } // ============================================================================= // Tool Use Trigger Checking // ============================================================================= /** * Checks if a tool_use matches a trigger. */ export function checkToolUseTrigger( message: ParsedMessage, trigger: NotificationTrigger, sessionId: string, projectId: string, filePath: string, lineNumber: number ): DetectedError | null { if (message.type !== 'assistant') return null; const contentBlocks = getContentBlocks(message); for (const block of contentBlocks) { if (block.type !== 'tool_use') continue; const toolUse = block as { type: 'tool_use'; id: string; name: string; input?: Record; }; // Check tool name if specified if (trigger.toolName && toolUse.name !== trigger.toolName) { continue; } // Extract the field to match based on matchField // If no matchField specified (e.g., "Any Tool"), match against entire input JSON const fieldValue = trigger.matchField ? extractToolUseField(toolUse, trigger.matchField) : toolUse.input ? JSON.stringify(toolUse.input) : null; if (!fieldValue) continue; // Check match pattern if (trigger.matchPattern && !matchesPattern(fieldValue, trigger.matchPattern)) { continue; } // Check ignore patterns if (matchesIgnorePatterns(fieldValue, trigger.ignorePatterns)) { continue; } // Match found! return createDetectedError({ sessionId, projectId, filePath, projectName: extractProjectName(projectId), lineNumber, source: toolUse.name, message: `${trigger.matchField ?? 'tool_use'}: ${fieldValue.slice(0, 200)}`, timestamp: message.timestamp, cwd: message.cwd, toolUseId: toolUse.id, triggerColor: trigger.color, triggerId: trigger.id, triggerName: trigger.name, }); } return null; } // ============================================================================= // Token Threshold Trigger Checking // ============================================================================= /** * Check if individual tool_use blocks exceed the token threshold. * Returns an array of DetectedError for each tool_use that exceeds the threshold. * * Token calculation (matches context window impact): * - Tool call tokens: estimated from name + JSON.stringify(input) (what enters context) * - Tool result tokens: estimated from tool_result.content (what Claude reads) * - Total = call + result */ export function checkTokenThresholdTrigger( message: ParsedMessage, trigger: NotificationTrigger, toolResultMap: Map, sessionId: string, projectId: string, filePath: string, lineNumber: number ): DetectedError[] { const errors: DetectedError[] = []; // Only check for token_threshold mode if (trigger.mode !== 'token_threshold' || !trigger.tokenThreshold) { return errors; } // Only check assistant messages that contain tool_use blocks if (message.type !== 'assistant') { return errors; } const tokenType = trigger.tokenType ?? 'total'; const threshold = trigger.tokenThreshold; // Collect all tool_use blocks from message const toolUseBlocks: { id: string; name: string; input: Record }[] = []; // Check content array for tool_use blocks if (Array.isArray(message.content)) { for (const block of message.content) { if (block.type === 'tool_use') { const toolUse = block; toolUseBlocks.push({ id: toolUse.id, name: toolUse.name, input: toolUse.input || {}, }); } } } // Also check toolCalls array if present if (message.toolCalls) { for (const toolCall of message.toolCalls) { // Avoid duplicates if (!toolUseBlocks.some((t) => t.id === toolCall.id)) { toolUseBlocks.push({ id: toolCall.id, name: toolCall.name, input: toolCall.input || {}, }); } } } if (toolUseBlocks.length === 0) { return errors; } // Check each tool_use block individually for (const toolUse of toolUseBlocks) { // Check tool name filter if specified if (trigger.toolName && toolUse.name !== trigger.toolName) { continue; } // Calculate tool call tokens directly from name + input // This reflects what actually enters the context window const toolCallTokens = estimateTokens(toolUse.name + JSON.stringify(toolUse.input)); // Calculate tool result tokens (what Claude reads back) let toolResultTokens = 0; const toolResult = toolResultMap.get(toolUse.id); if (toolResult) { toolResultTokens = estimateTokens(toolResult.content); } // Calculate token count based on tokenType // Note: 'input' here means tool CALL tokens (what enters context) // 'output' here means tool RESULT tokens (what Claude reads) let tokenCount = 0; switch (tokenType) { case 'input': // Tool call tokens (name + input that enters context) tokenCount = toolCallTokens; break; case 'output': // Tool result tokens (what Claude reads - success message, file content for Read, etc.) tokenCount = toolResultTokens; break; case 'total': // Both: full context impact of the tool operation tokenCount = toolCallTokens + toolResultTokens; break; } // Check threshold if (tokenCount <= threshold) { continue; } // Build summary for the tool const toolSummary = getToolSummary(toolUse.name, toolUse.input); // Build message with tool info and token type for clarity const tokenTypeLabel = tokenType === 'total' ? '' : ` ${tokenType}`; const tokenMessage = `${toolUse.name} - ${toolSummary} : ~${formatTokens(tokenCount)}${tokenTypeLabel} tokens`; // Check ignore patterns if (matchesIgnorePatterns(tokenMessage, trigger.ignorePatterns)) { continue; } errors.push( createDetectedError({ sessionId, projectId, filePath, projectName: extractProjectName(projectId), lineNumber, source: toolUse.name, message: tokenMessage, timestamp: message.timestamp, cwd: message.cwd, toolUseId: toolUse.id, triggerColor: trigger.color, triggerId: trigger.id, triggerName: trigger.name, }) ); } return errors; }