import type { Base64ImageSource, ContentBlockParam, ToolResultBlockParam, } from '@anthropic-ai/sdk/resources/index.mjs' import { readFile, stat } from 'fs/promises' import { getOriginalCwd } from 'src/bootstrap/state.js' import { logEvent } from 'src/services/analytics/index.js' import type { ToolPermissionContext } from 'src/Tool.js' import { getCwd } from 'src/utils/cwd.js' import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js' import { setCwd } from 'src/utils/Shell.js' import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js' import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js' import { getMaxOutputLength } from '../../utils/shell/outputLimits.js' import { countCharInString, plural } from '../../utils/stringUtils.js' /** * Strips leading and trailing lines that contain only whitespace/newlines. * Unlike trim(), this preserves whitespace within content lines and only removes * completely empty lines from the beginning and end. */ export function stripEmptyLines(content: string): string { const lines = content.split('\n') // Find the first non-empty line let startIndex = 0 while (startIndex < lines.length && lines[startIndex]?.trim() === '') { startIndex++ } // Find the last non-empty line let endIndex = lines.length - 1 while (endIndex >= 0 && lines[endIndex]?.trim() === '') { endIndex-- } // If all lines are empty, return empty string if (startIndex > endIndex) { return '' } // Return the slice with non-empty lines return lines.slice(startIndex, endIndex + 1).join('\n') } /** * Check if content is a base64 encoded image data URL */ export function isImageOutput(content: string): boolean { return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content) } const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/ /** * Parse a data-URI string into its media type and base64 payload. * Input is trimmed before matching. */ export function parseDataUri( s: string, ): { mediaType: string; data: string } | null { const match = s.trim().match(DATA_URI_RE) if (!match || !match[1] || !match[2]) return null return { mediaType: match[1], data: match[2] } } /** * Build an image tool_result block from shell stdout containing a data URI. * Returns null if parse fails so callers can fall through to text handling. */ export function buildImageToolResult( stdout: string, toolUseID: string, ): ToolResultBlockParam | null { const parsed = parseDataUri(stdout) if (!parsed) return null return { tool_use_id: toolUseID, type: 'tool_result', content: [ { type: 'image', source: { type: 'base64', media_type: parsed.mediaType as Base64ImageSource['media_type'], data: parsed.data, }, }, ], } } // Cap file reads to 20 MB — any image data URI larger than this is // well beyond what the API accepts (5 MB base64) and would OOM if read // into memory. const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024 /** * Resize image output from a shell tool. stdout is capped at * getMaxOutputLength() when read back from the shell output file — if the * full output spilled to disk, re-read it from there, since truncated base64 * would decode to a corrupt image that either throws here or gets rejected by * the API. Caps dimensions too: compressImageBuffer only checks byte size, so * a small-but-high-DPI PNG (e.g. matplotlib at dpi=300) sails through at full * resolution and poisons many-image requests (CC-304). * * Returns the re-encoded data URI on success, or null if the source didn't * parse as a data URI (caller decides whether to flip isImage). */ export async function resizeShellImageOutput( stdout: string, outputFilePath: string | undefined, outputFileSize: number | undefined, ): Promise { let source = stdout if (outputFilePath) { const size = outputFileSize ?? (await stat(outputFilePath)).size if (size > MAX_IMAGE_FILE_SIZE) return null source = await readFile(outputFilePath, 'utf8') } const parsed = parseDataUri(source) if (!parsed) return null const buf = Buffer.from(parsed.data, 'base64') const ext = parsed.mediaType.split('/')[1] || 'png' const resized = await maybeResizeAndDownsampleImageBuffer( buf, buf.length, ext, ) return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}` } export function formatOutput(content: string): { totalLines: number truncatedContent: string isImage?: boolean } { const isImage = isImageOutput(content) if (isImage) { return { totalLines: 1, truncatedContent: content, isImage, } } const maxOutputLength = getMaxOutputLength() if (content.length <= maxOutputLength) { return { totalLines: countCharInString(content, '\n') + 1, truncatedContent: content, isImage, } } const truncatedPart = content.slice(0, maxOutputLength) const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1 const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...` return { totalLines: countCharInString(content, '\n') + 1, truncatedContent: truncated, isImage, } } export const stdErrAppendShellResetMessage = (stderr: string): string => `${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}` export function resetCwdIfOutsideProject( toolPermissionContext: ToolPermissionContext, ): boolean { const cwd = getCwd() const originalCwd = getOriginalCwd() const shouldMaintain = shouldMaintainProjectWorkingDir() if ( shouldMaintain || // Fast path: originalCwd is unconditionally in allWorkingDirectories // (filesystem.ts), so when cwd hasn't moved, pathInAllowedWorkingPath is // trivially true — skip its syscalls for the no-cd common case. (cwd !== originalCwd && !pathInAllowedWorkingPath(cwd, toolPermissionContext)) ) { // Reset to original directory if maintaining project dir OR outside allowed working directory setCwd(originalCwd) if (!shouldMaintain) { logEvent('tengu_bash_tool_reset_to_original_dir', {}) return true } } return false } /** * Creates a human-readable summary of structured content blocks. * Used to display MCP results with images and text in the UI. */ export function createContentSummary(content: ContentBlockParam[]): string { const parts: string[] = [] let textCount = 0 let imageCount = 0 for (const block of content) { if (block.type === 'image') { imageCount++ } else if (block.type === 'text' && 'text' in block) { textCount++ // Include first 200 chars of text blocks for context const preview = block.text.slice(0, 200) parts.push(preview + (block.text.length > 200 ? '...' : '')) } } const summary: string[] = [] if (imageCount > 0) { summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`) } if (textCount > 0) { summary.push(`[${textCount} text ${plural(textCount, 'block')}]`) } return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}` }