Download src/tools/BashTool/utils.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 7.21 kB
-
https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/BashTool/utils.ts
- Command line
-
hf download hf://chenbhao/codev@e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/BashTool/utils.ts
-
curl -L -o utils.ts https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/BashTool/utils.ts
7.21 kB
| 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<string | null> { | |
| 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') : ''}` | |
| } | |