init claude-code
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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') : ''}`
|
||||
}
|
||||
Reference in New Issue
Block a user