source dump of claude code
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}