source dump of claude code
at main 223 lines 7.2 kB view raw
1import type { 2 Base64ImageSource, 3 ContentBlockParam, 4 ToolResultBlockParam, 5} from '@anthropic-ai/sdk/resources/index.mjs' 6import { readFile, stat } from 'fs/promises' 7import { getOriginalCwd } from 'src/bootstrap/state.js' 8import { logEvent } from 'src/services/analytics/index.js' 9import type { ToolPermissionContext } from 'src/Tool.js' 10import { getCwd } from 'src/utils/cwd.js' 11import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js' 12import { setCwd } from 'src/utils/Shell.js' 13import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js' 14import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js' 15import { getMaxOutputLength } from '../../utils/shell/outputLimits.js' 16import { countCharInString, plural } from '../../utils/stringUtils.js' 17/** 18 * Strips leading and trailing lines that contain only whitespace/newlines. 19 * Unlike trim(), this preserves whitespace within content lines and only removes 20 * completely empty lines from the beginning and end. 21 */ 22export function stripEmptyLines(content: string): string { 23 const lines = content.split('\n') 24 25 // Find the first non-empty line 26 let startIndex = 0 27 while (startIndex < lines.length && lines[startIndex]?.trim() === '') { 28 startIndex++ 29 } 30 31 // Find the last non-empty line 32 let endIndex = lines.length - 1 33 while (endIndex >= 0 && lines[endIndex]?.trim() === '') { 34 endIndex-- 35 } 36 37 // If all lines are empty, return empty string 38 if (startIndex > endIndex) { 39 return '' 40 } 41 42 // Return the slice with non-empty lines 43 return lines.slice(startIndex, endIndex + 1).join('\n') 44} 45 46/** 47 * Check if content is a base64 encoded image data URL 48 */ 49export function isImageOutput(content: string): boolean { 50 return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content) 51} 52 53const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/ 54 55/** 56 * Parse a data-URI string into its media type and base64 payload. 57 * Input is trimmed before matching. 58 */ 59export function parseDataUri( 60 s: string, 61): { mediaType: string; data: string } | null { 62 const match = s.trim().match(DATA_URI_RE) 63 if (!match || !match[1] || !match[2]) return null 64 return { mediaType: match[1], data: match[2] } 65} 66 67/** 68 * Build an image tool_result block from shell stdout containing a data URI. 69 * Returns null if parse fails so callers can fall through to text handling. 70 */ 71export function buildImageToolResult( 72 stdout: string, 73 toolUseID: string, 74): ToolResultBlockParam | null { 75 const parsed = parseDataUri(stdout) 76 if (!parsed) return null 77 return { 78 tool_use_id: toolUseID, 79 type: 'tool_result', 80 content: [ 81 { 82 type: 'image', 83 source: { 84 type: 'base64', 85 media_type: parsed.mediaType as Base64ImageSource['media_type'], 86 data: parsed.data, 87 }, 88 }, 89 ], 90 } 91} 92 93// Cap file reads to 20 MB — any image data URI larger than this is 94// well beyond what the API accepts (5 MB base64) and would OOM if read 95// into memory. 96const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024 97 98/** 99 * Resize image output from a shell tool. stdout is capped at 100 * getMaxOutputLength() when read back from the shell output file — if the 101 * full output spilled to disk, re-read it from there, since truncated base64 102 * would decode to a corrupt image that either throws here or gets rejected by 103 * the API. Caps dimensions too: compressImageBuffer only checks byte size, so 104 * a small-but-high-DPI PNG (e.g. matplotlib at dpi=300) sails through at full 105 * resolution and poisons many-image requests (CC-304). 106 * 107 * Returns the re-encoded data URI on success, or null if the source didn't 108 * parse as a data URI (caller decides whether to flip isImage). 109 */ 110export async function resizeShellImageOutput( 111 stdout: string, 112 outputFilePath: string | undefined, 113 outputFileSize: number | undefined, 114): Promise<string | null> { 115 let source = stdout 116 if (outputFilePath) { 117 const size = outputFileSize ?? (await stat(outputFilePath)).size 118 if (size > MAX_IMAGE_FILE_SIZE) return null 119 source = await readFile(outputFilePath, 'utf8') 120 } 121 const parsed = parseDataUri(source) 122 if (!parsed) return null 123 const buf = Buffer.from(parsed.data, 'base64') 124 const ext = parsed.mediaType.split('/')[1] || 'png' 125 const resized = await maybeResizeAndDownsampleImageBuffer( 126 buf, 127 buf.length, 128 ext, 129 ) 130 return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}` 131} 132 133export function formatOutput(content: string): { 134 totalLines: number 135 truncatedContent: string 136 isImage?: boolean 137} { 138 const isImage = isImageOutput(content) 139 if (isImage) { 140 return { 141 totalLines: 1, 142 truncatedContent: content, 143 isImage, 144 } 145 } 146 147 const maxOutputLength = getMaxOutputLength() 148 if (content.length <= maxOutputLength) { 149 return { 150 totalLines: countCharInString(content, '\n') + 1, 151 truncatedContent: content, 152 isImage, 153 } 154 } 155 156 const truncatedPart = content.slice(0, maxOutputLength) 157 const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1 158 const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...` 159 160 return { 161 totalLines: countCharInString(content, '\n') + 1, 162 truncatedContent: truncated, 163 isImage, 164 } 165} 166 167export const stdErrAppendShellResetMessage = (stderr: string): string => 168 `${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}` 169 170export function resetCwdIfOutsideProject( 171 toolPermissionContext: ToolPermissionContext, 172): boolean { 173 const cwd = getCwd() 174 const originalCwd = getOriginalCwd() 175 const shouldMaintain = shouldMaintainProjectWorkingDir() 176 if ( 177 shouldMaintain || 178 // Fast path: originalCwd is unconditionally in allWorkingDirectories 179 // (filesystem.ts), so when cwd hasn't moved, pathInAllowedWorkingPath is 180 // trivially true — skip its syscalls for the no-cd common case. 181 (cwd !== originalCwd && 182 !pathInAllowedWorkingPath(cwd, toolPermissionContext)) 183 ) { 184 // Reset to original directory if maintaining project dir OR outside allowed working directory 185 setCwd(originalCwd) 186 if (!shouldMaintain) { 187 logEvent('tengu_bash_tool_reset_to_original_dir', {}) 188 return true 189 } 190 } 191 return false 192} 193 194/** 195 * Creates a human-readable summary of structured content blocks. 196 * Used to display MCP results with images and text in the UI. 197 */ 198export function createContentSummary(content: ContentBlockParam[]): string { 199 const parts: string[] = [] 200 let textCount = 0 201 let imageCount = 0 202 203 for (const block of content) { 204 if (block.type === 'image') { 205 imageCount++ 206 } else if (block.type === 'text' && 'text' in block) { 207 textCount++ 208 // Include first 200 chars of text blocks for context 209 const preview = block.text.slice(0, 200) 210 parts.push(preview + (block.text.length > 200 ? '...' : '')) 211 } 212 } 213 214 const summary: string[] = [] 215 if (imageCount > 0) { 216 summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`) 217 } 218 if (textCount > 0) { 219 summary.push(`[${textCount} text ${plural(textCount, 'block')}]`) 220 } 221 222 return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}` 223}