/ tools / BashTool / utils.ts
utils.ts
  1  import type {
  2    Base64ImageSource,
  3    ContentBlockParam,
  4    ToolResultBlockParam,
  5  } from '@anthropic-ai/sdk/resources/index.mjs'
  6  import { readFile, stat } from 'fs/promises'
  7  import { getOriginalCwd } from 'src/bootstrap/state.js'
  8  import { logEvent } from 'src/services/analytics/index.js'
  9  import type { ToolPermissionContext } from 'src/Tool.js'
 10  import { getCwd } from 'src/utils/cwd.js'
 11  import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js'
 12  import { setCwd } from 'src/utils/Shell.js'
 13  import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js'
 14  import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js'
 15  import { getMaxOutputLength } from '../../utils/shell/outputLimits.js'
 16  import { 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   */
 22  export 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   */
 49  export function isImageOutput(content: string): boolean {
 50    return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content)
 51  }
 52  
 53  const 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   */
 59  export 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   */
 71  export 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.
 96  const 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   */
110  export 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  
133  export 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  
167  export const stdErrAppendShellResetMessage = (stderr: string): string =>
168    `${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}`
169  
170  export 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   */
198  export 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  }