2025-05-29 22:30:18 +00:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-07-21 08:16:42 +09:00
import fs from 'node:fs' ;
2025-09-08 14:44:56 -07:00
import fsPromises from 'node:fs/promises' ;
2025-07-21 08:16:42 +09:00
import path from 'node:path' ;
2025-08-26 00:04:53 +02:00
import type { PartUnion } from '@google/genai' ;
2026-04-14 00:28:18 +05:30
import { isBinaryFile as isBinaryFileCheck } from 'isbinaryfile' ;
2025-09-04 23:00:27 +02:00
import mime from 'mime/lite' ;
2025-08-26 00:04:53 +02:00
import type { FileSystemService } from '../services/fileSystemService.js' ;
2025-08-20 16:13:29 -07:00
import { ToolErrorType } from '../tools/tool-error.js' ;
2025-08-23 13:35:00 +09:00
import { BINARY_EXTENSIONS } from './ignorePatterns.js' ;
2025-10-16 17:25:30 -07:00
import { createRequire as createModuleRequire } from 'node:module' ;
2025-10-21 16:35:22 -04:00
import { debugLogger } from './debugLogger.js' ;
2026-03-02 15:11:58 -05:00
import {
DEFAULT_MAX_LINES_TEXT_FILE ,
MAX_LINE_LENGTH_TEXT_FILE ,
MAX_FILE_SIZE_MB ,
} from './constants.js' ;
2025-10-16 17:25:30 -07:00
const requireModule = createModuleRequire ( import . meta . url ) ;
export async function readWasmBinaryFromDisk (
specifier : string ,
) : Promise < Uint8Array > {
const resolvedPath = requireModule . resolve ( specifier ) ;
const buffer = await fsPromises . readFile ( resolvedPath ) ;
return new Uint8Array ( buffer ) ;
}
export async function loadWasmBinary (
dynamicImport : ( ) = > Promise < { default : Uint8Array } > ,
fallbackSpecifier : string ,
) : Promise < Uint8Array > {
try {
const module = await dynamicImport ( ) ;
if ( module ? . default instanceof Uint8Array ) {
return module . default ;
}
} catch ( error ) {
try {
return await readWasmBinaryFromDisk ( fallbackSpecifier ) ;
} catch {
throw error ;
}
}
try {
return await readWasmBinaryFromDisk ( fallbackSpecifier ) ;
} catch ( error ) {
throw new Error ( 'WASM binary module did not provide a Uint8Array export' , {
cause : error ,
} ) ;
}
}
2025-05-29 22:30:18 +00:00
// Default values for encoding and separator format
export const DEFAULT_ENCODING : BufferEncoding = 'utf-8' ;
2025-08-28 21:13:46 -04:00
// --- Unicode BOM detection & decoding helpers --------------------------------
type UnicodeEncoding = 'utf8' | 'utf16le' | 'utf16be' | 'utf32le' | 'utf32be' ;
interface BOMInfo {
encoding : UnicodeEncoding ;
bomLength : number ;
}
/**
* Detect a Unicode BOM (Byte Order Mark) if present.
* Reads up to the first 4 bytes and returns encoding + BOM length, else null.
*/
export function detectBOM ( buf : Buffer ) : BOMInfo | null {
if ( buf . length >= 4 ) {
// UTF-32 LE: FF FE 00 00
if (
buf [ 0 ] === 0xff &&
buf [ 1 ] === 0xfe &&
buf [ 2 ] === 0x00 &&
buf [ 3 ] === 0x00
) {
return { encoding : 'utf32le' , bomLength : 4 } ;
}
// UTF-32 BE: 00 00 FE FF
if (
buf [ 0 ] === 0x00 &&
buf [ 1 ] === 0x00 &&
buf [ 2 ] === 0xfe &&
buf [ 3 ] === 0xff
) {
return { encoding : 'utf32be' , bomLength : 4 } ;
}
}
if ( buf . length >= 3 ) {
// UTF-8: EF BB BF
if ( buf [ 0 ] === 0xef && buf [ 1 ] === 0xbb && buf [ 2 ] === 0xbf ) {
return { encoding : 'utf8' , bomLength : 3 } ;
}
}
if ( buf . length >= 2 ) {
// UTF-16 LE: FF FE (but not UTF-32 LE already matched above)
if (
buf [ 0 ] === 0xff &&
buf [ 1 ] === 0xfe &&
( buf . length < 4 || buf [ 2 ] !== 0x00 || buf [ 3 ] !== 0x00 )
) {
return { encoding : 'utf16le' , bomLength : 2 } ;
}
// UTF-16 BE: FE FF
if ( buf [ 0 ] === 0xfe && buf [ 1 ] === 0xff ) {
return { encoding : 'utf16be' , bomLength : 2 } ;
}
}
return null ;
}
/**
* Convert a UTF-16 BE buffer to a JS string by swapping to LE then using Node's decoder.
* (Node has 'utf16le' but not 'utf16be'.)
*/
function decodeUTF16BE ( buf : Buffer ) : string {
if ( buf . length === 0 ) return '' ;
const swapped = Buffer . from ( buf ) ; // swap16 mutates in place, so copy
swapped . swap16 ( ) ;
return swapped . toString ( 'utf16le' ) ;
}
/**
* Decode a UTF-32 buffer (LE or BE) into a JS string.
* Invalid code points are replaced with U+FFFD, partial trailing bytes are ignored.
*/
function decodeUTF32 ( buf : Buffer , littleEndian : boolean ) : string {
if ( buf . length < 4 ) return '' ;
const usable = buf . length - ( buf . length % 4 ) ;
let out = '' ;
for ( let i = 0 ; i < usable ; i += 4 ) {
const cp = littleEndian
? ( buf [ i ] |
( buf [ i + 1 ] << 8 ) |
( buf [ i + 2 ] << 16 ) |
( buf [ i + 3 ] << 24 ) ) >>>
0
: ( buf [ i + 3 ] |
( buf [ i + 2 ] << 8 ) |
( buf [ i + 1 ] << 16 ) |
( buf [ i ] << 24 ) ) >>>
0 ;
// Valid planes: 0x0000..0x10FFFF excluding surrogates
if ( cp <= 0x10ffff && ! ( cp >= 0xd800 && cp <= 0xdfff ) ) {
out += String . fromCodePoint ( cp ) ;
} else {
out += '\uFFFD' ;
}
}
return out ;
}
/**
* Read a file as text, honoring BOM encodings (UTF‑ 8/16/32) and stripping the BOM.
* Falls back to utf8 when no BOM is present.
*/
export async function readFileWithEncoding ( filePath : string ) : Promise < string > {
// Read the file once; detect BOM and decode from the single buffer.
const full = await fs . promises . readFile ( filePath ) ;
if ( full . length === 0 ) return '' ;
const bom = detectBOM ( full ) ;
if ( ! bom ) {
// No BOM → treat as UTF‑ 8
return full . toString ( 'utf8' ) ;
}
// Strip BOM and decode per encoding
const content = full . subarray ( bom . bomLength ) ;
switch ( bom . encoding ) {
case 'utf8' :
return content . toString ( 'utf8' ) ;
case 'utf16le' :
return content . toString ( 'utf16le' ) ;
case 'utf16be' :
return decodeUTF16BE ( content ) ;
case 'utf32le' :
return decodeUTF32 ( content , true ) ;
case 'utf32be' :
return decodeUTF32 ( content , false ) ;
default :
// Defensive fallback; should be unreachable
return content . toString ( 'utf8' ) ;
}
}
2025-06-15 16:24:53 -04:00
/**
* Looks up the specific MIME type for a file path.
* @param filePath Path to the file.
* @returns The specific MIME type string (e.g., 'text/python', 'application/javascript') or undefined if not found or ambiguous.
*/
export function getSpecificMimeType ( filePath : string ) : string | undefined {
2025-09-04 23:00:27 +02:00
const lookedUpMime = mime . getType ( filePath ) ;
2025-06-15 16:24:53 -04:00
return typeof lookedUpMime === 'string' ? lookedUpMime : undefined ;
}
2026-04-11 02:46:24 +05:00
const SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION = new Map < string , string > ( [
[ '.mp3' , 'audio/mpeg' ] ,
[ '.wav' , 'audio/wav' ] ,
[ '.aiff' , 'audio/aiff' ] ,
[ '.aif' , 'audio/aiff' ] ,
[ '.aac' , 'audio/aac' ] ,
[ '.ogg' , 'audio/ogg' ] ,
[ '.flac' , 'audio/flac' ] ,
] ) ;
const AUDIO_MIME_TYPE_NORMALIZATION : Record < string , string > = {
'audio/mp3' : 'audio/mpeg' ,
'audio/x-mp3' : 'audio/mpeg' ,
'audio/wave' : 'audio/wav' ,
'audio/x-wav' : 'audio/wav' ,
'audio/vnd.wave' : 'audio/wav' ,
'audio/x-pn-wav' : 'audio/wav' ,
'audio/x-aiff' : 'audio/aiff' ,
'audio/aif' : 'audio/aiff' ,
'audio/x-aac' : 'audio/aac' ,
} ;
function formatSupportedAudioFormats ( ) : string {
const displayNames = Array . from (
new Set (
Array . from ( SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION . keys ( ) ) . map ( ( ext ) = > {
if ( ext === '.aif' || ext === '.aiff' ) {
return 'AIFF' ;
}
return ext . slice ( 1 ) . toUpperCase ( ) ;
} ) ,
) ,
) ;
if ( displayNames . length <= 1 ) {
return displayNames [ 0 ] ? ? '' ;
}
return ` ${ displayNames . slice ( 0 , - 1 ) . join ( ', ' ) } , and ${ displayNames . at ( - 1 ) } ` ;
}
const SUPPORTED_AUDIO_FORMATS_DISPLAY = formatSupportedAudioFormats ( ) ;
function getSupportedAudioMimeTypeForFile (
filePath : string ,
) : string | undefined {
const extension = path . extname ( filePath ) . toLowerCase ( ) ;
const extensionMimeType =
SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION . get ( extension ) ;
const lookedUpMimeType = getSpecificMimeType ( filePath ) ? . toLowerCase ( ) ;
const normalizedMimeType = lookedUpMimeType
? ( AUDIO_MIME_TYPE_NORMALIZATION [ lookedUpMimeType ] ? ? lookedUpMimeType )
: undefined ;
if (
normalizedMimeType &&
[ . . . SUPPORTED_AUDIO_MIME_TYPES_BY_EXTENSION . values ( ) ] . includes (
normalizedMimeType ,
)
) {
return normalizedMimeType ;
}
return extensionMimeType ;
}
2025-05-29 22:30:18 +00:00
/**
* Checks if a path is within a given root directory.
* @param pathToCheck The absolute path to check.
* @param rootDirectory The absolute root directory.
* @returns True if the path is within the root directory, false otherwise.
*/
export function isWithinRoot (
pathToCheck : string ,
rootDirectory : string ,
) : boolean {
2025-07-14 22:55:49 -07:00
const normalizedPathToCheck = path . resolve ( pathToCheck ) ;
const normalizedRootDirectory = path . resolve ( rootDirectory ) ;
2025-05-29 22:30:18 +00:00
// Ensure the rootDirectory path ends with a separator for correct startsWith comparison,
// unless it's the root path itself (e.g., '/' or 'C:\').
const rootWithSeparator =
normalizedRootDirectory === path . sep ||
normalizedRootDirectory . endsWith ( path . sep )
? normalizedRootDirectory
: normalizedRootDirectory + path . sep ;
return (
normalizedPathToCheck === normalizedRootDirectory ||
normalizedPathToCheck . startsWith ( rootWithSeparator )
) ;
}
2026-02-02 22:30:03 -05:00
/**
* Safely resolves a path to its real path if it exists, otherwise returns the absolute resolved path.
*/
export function getRealPath ( filePath : string ) : string {
try {
return fs . realpathSync ( filePath ) ;
} catch {
return path . resolve ( filePath ) ;
}
}
/**
* Checks if a file's content is empty or contains only whitespace.
* Efficiently checks file size first, and only samples the beginning of the file.
* Honors Unicode BOM encodings.
*/
export async function isEmpty ( filePath : string ) : Promise < boolean > {
try {
const stats = await fsPromises . stat ( filePath ) ;
if ( stats . size === 0 ) return true ;
// Sample up to 1KB to check for non-whitespace content.
// If a file is larger than 1KB and contains only whitespace,
// it's an extreme edge case we can afford to read slightly more of if needed,
// but for most valid plans/files, this is sufficient.
const fd = await fsPromises . open ( filePath , 'r' ) ;
try {
const { buffer } = await fd . read ( {
buffer : Buffer.alloc ( Math . min ( 1024 , stats . size ) ) ,
offset : 0 ,
length : Math.min ( 1024 , stats . size ) ,
position : 0 ,
} ) ;
const bom = detectBOM ( buffer ) ;
const content = bom
? buffer . subarray ( bom . bomLength ) . toString ( 'utf8' )
: buffer . toString ( 'utf8' ) ;
return content . trim ( ) . length === 0 ;
} finally {
await fd . close ( ) ;
}
} catch {
// If file is unreadable, we treat it as empty/invalid for validation purposes
return true ;
}
}
2025-05-29 22:30:18 +00:00
/**
2025-08-28 21:13:46 -04:00
* Heuristic: determine if a file is likely binary.
2026-04-14 00:28:18 +05:30
* Delegates to the `isbinaryfile` package for UTF-8-aware detection.
2025-05-29 22:30:18 +00:00
*/
2025-07-21 08:16:42 +09:00
export async function isBinaryFile ( filePath : string ) : Promise < boolean > {
2025-05-29 22:30:18 +00:00
try {
2026-04-14 00:28:18 +05:30
return await isBinaryFileCheck ( filePath ) ;
2025-07-21 08:16:42 +09:00
} catch ( error ) {
2025-10-21 16:35:22 -04:00
debugLogger . warn (
2025-07-21 08:16:42 +09:00
` Failed to check if file is binary: ${ filePath } ` ,
error instanceof Error ? error.message : String ( error ) ,
) ;
2025-05-29 22:30:18 +00:00
return false ;
}
}
/**
* Detects the type of file based on extension and content.
* @param filePath Path to the file.
2025-07-21 08:16:42 +09:00
* @returns Promise that resolves to 'text', 'image', 'pdf', 'audio', 'video', 'binary' or 'svg'.
2025-05-29 22:30:18 +00:00
*/
2025-07-21 08:16:42 +09:00
export async function detectFileType (
2025-05-29 22:30:18 +00:00
filePath : string ,
2025-07-21 08:16:42 +09:00
) : Promise < 'text' | 'image' | 'pdf' | 'audio' | 'video' | 'binary' | 'svg' > {
2025-05-29 22:30:18 +00:00
const ext = path . extname ( filePath ) . toLowerCase ( ) ;
2025-08-05 23:15:53 -07:00
// The mimetype for various TypeScript extensions (ts, mts, cts, tsx) can be
// MPEG transport stream (a video format), but we want to assume these are
// TypeScript files instead.
if ( [ '.ts' , '.mts' , '.cts' ] . includes ( ext ) ) {
2025-07-01 16:05:33 -07:00
return 'text' ;
}
2025-07-07 11:21:32 +05:30
if ( ext === '.svg' ) {
return 'svg' ;
}
2025-09-04 23:00:27 +02:00
const lookedUpMimeType = mime . getType ( filePath ) ; // Returns null if not found, or the mime type string
2025-07-02 00:52:32 +05:30
if ( lookedUpMimeType ) {
if ( lookedUpMimeType . startsWith ( 'image/' ) ) {
return 'image' ;
}
2026-01-28 23:58:39 +09:00
// Verify audio/video with content check to avoid MIME misidentification (#16888)
if (
lookedUpMimeType . startsWith ( 'audio/' ) ||
lookedUpMimeType . startsWith ( 'video/' )
) {
if ( ! ( await isBinaryFile ( filePath ) ) ) {
return 'text' ;
}
return lookedUpMimeType . startsWith ( 'audio/' ) ? 'audio' : 'video' ;
2025-07-02 00:52:32 +05:30
}
if ( lookedUpMimeType === 'application/pdf' ) {
return 'pdf' ;
}
2025-05-29 22:30:18 +00:00
}
2026-04-11 02:46:24 +05:00
const supportedAudioMimeType = getSupportedAudioMimeTypeForFile ( filePath ) ;
if ( supportedAudioMimeType ) {
if ( ! ( await isBinaryFile ( filePath ) ) ) {
return 'text' ;
}
return 'audio' ;
}
2025-05-29 22:30:18 +00:00
// Stricter binary check for common non-text extensions before content check
// These are often not well-covered by mime-types or might be misidentified.
2025-08-23 13:35:00 +09:00
if ( BINARY_EXTENSIONS . includes ( ext ) ) {
2025-05-29 22:30:18 +00:00
return 'binary' ;
}
2025-07-21 17:54:44 -04:00
// Fall back to content-based check if mime type wasn't conclusive for image/pdf
2025-05-29 22:30:18 +00:00
// and it's not a known binary extension.
2025-07-21 08:16:42 +09:00
if ( await isBinaryFile ( filePath ) ) {
2025-05-29 22:30:18 +00:00
return 'binary' ;
}
return 'text' ;
}
export interface ProcessedFileReadResult {
llmContent : PartUnion ; // string for text, Part for image/pdf/unreadable binary
returnDisplay : string ;
error? : string ; // Optional error message for the LLM if file processing failed
2025-08-20 16:13:29 -07:00
errorType? : ToolErrorType ; // Structured error type
2025-05-29 22:30:18 +00:00
isTruncated? : boolean ; // For text files, indicates if content was truncated
originalLineCount? : number ; // For text files
linesShown ? : [ number , number ] ; // For text files [startLine, endLine] (1-based for display)
}
/**
* Reads and processes a single file, handling text, images, and PDFs.
* @param filePath Absolute path to the file.
* @param rootDirectory Absolute path to the project root for relative path display.
2026-02-20 17:59:18 -05:00
* @param _fileSystemService Currently unused in this function; kept for signature stability.
* @param startLine Optional 1-based line number to start reading from.
* @param endLine Optional 1-based line number to end reading at (inclusive).
2025-05-29 22:30:18 +00:00
* @returns ProcessedFileReadResult object.
*/
export async function processSingleFileContent (
filePath : string ,
rootDirectory : string ,
2026-02-20 17:59:18 -05:00
_fileSystemService : FileSystemService ,
startLine? : number ,
endLine? : number ,
2025-05-29 22:30:18 +00:00
) : Promise < ProcessedFileReadResult > {
try {
if ( ! fs . existsSync ( filePath ) ) {
// Sync check is acceptable before async read
return {
2025-08-20 16:13:29 -07:00
llmContent :
'Could not read file because no file was found at the specified path.' ,
2025-05-29 22:30:18 +00:00
returnDisplay : 'File not found.' ,
error : ` File not found: ${ filePath } ` ,
2025-08-20 16:13:29 -07:00
errorType : ToolErrorType.FILE_NOT_FOUND ,
2025-05-29 22:30:18 +00:00
} ;
}
2025-07-02 00:52:32 +05:30
const stats = await fs . promises . stat ( filePath ) ;
2025-05-29 22:30:18 +00:00
if ( stats . isDirectory ( ) ) {
return {
2025-08-20 16:13:29 -07:00
llmContent :
'Could not read file because the provided path is a directory, not a file.' ,
2025-05-29 22:30:18 +00:00
returnDisplay : 'Path is a directory.' ,
error : ` Path is a directory, not a file: ${ filePath } ` ,
2025-08-20 16:13:29 -07:00
errorType : ToolErrorType.TARGET_IS_DIRECTORY ,
2025-05-29 22:30:18 +00:00
} ;
}
2025-08-20 16:13:29 -07:00
const fileSizeInMB = stats . size / ( 1024 * 1024 ) ;
2026-03-02 15:11:58 -05:00
if ( fileSizeInMB > MAX_FILE_SIZE_MB ) {
2025-08-20 16:13:29 -07:00
return {
2026-03-02 15:11:58 -05:00
llmContent : ` File size exceeds the ${ MAX_FILE_SIZE_MB } MB limit. ` ,
returnDisplay : ` File size exceeds the ${ MAX_FILE_SIZE_MB } MB limit. ` ,
error : ` File size exceeds the ${ MAX_FILE_SIZE_MB } MB limit: ${ filePath } ( ${ fileSizeInMB . toFixed ( 2 ) } MB) ` ,
2025-08-20 16:13:29 -07:00
errorType : ToolErrorType.FILE_TOO_LARGE ,
} ;
2025-07-02 00:52:32 +05:30
}
2025-07-21 08:16:42 +09:00
const fileType = await detectFileType ( filePath ) ;
2025-05-29 22:30:18 +00:00
const relativePathForDisplay = path
. relative ( rootDirectory , filePath )
. replace ( /\\/g , '/' ) ;
switch ( fileType ) {
case 'binary' : {
return {
llmContent : ` Cannot display content of binary file: ${ relativePathForDisplay } ` ,
returnDisplay : ` Skipped binary file: ${ relativePathForDisplay } ` ,
} ;
}
2025-07-07 11:21:32 +05:30
case 'svg' : {
const SVG_MAX_SIZE_BYTES = 1 * 1024 * 1024 ;
if ( stats . size > SVG_MAX_SIZE_BYTES ) {
return {
llmContent : ` Cannot display content of SVG file larger than 1MB: ${ relativePathForDisplay } ` ,
returnDisplay : ` Skipped large SVG file (>1MB): ${ relativePathForDisplay } ` ,
} ;
}
2025-08-28 21:13:46 -04:00
const content = await readFileWithEncoding ( filePath ) ;
2025-07-07 11:21:32 +05:30
return {
llmContent : content ,
returnDisplay : ` Read SVG as text: ${ relativePathForDisplay } ` ,
} ;
}
2025-05-29 22:30:18 +00:00
case 'text' : {
2025-08-28 21:13:46 -04:00
// Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently
const content = await readFileWithEncoding ( filePath ) ;
2026-03-10 03:40:22 +05:00
const lines = content . split ( /\r?\n/ ) ;
2025-05-29 22:30:18 +00:00
const originalLineCount = lines . length ;
2026-02-20 17:59:18 -05:00
let sliceStart = 0 ;
let sliceEnd = originalLineCount ;
if ( startLine !== undefined || endLine !== undefined ) {
sliceStart = startLine ? startLine - 1 : 0 ;
sliceEnd = endLine
? Math . min ( endLine , originalLineCount )
: Math . min (
sliceStart + DEFAULT_MAX_LINES_TEXT_FILE ,
originalLineCount ,
) ;
} else {
sliceEnd = Math . min ( DEFAULT_MAX_LINES_TEXT_FILE , originalLineCount ) ;
}
// Ensure selectedLines doesn't try to slice beyond array bounds
const actualStart = Math . min ( sliceStart , originalLineCount ) ;
const selectedLines = lines . slice ( actualStart , sliceEnd ) ;
2025-05-29 22:30:18 +00:00
let linesWereTruncatedInLength = false ;
const formattedLines = selectedLines . map ( ( line ) = > {
if ( line . length > MAX_LINE_LENGTH_TEXT_FILE ) {
linesWereTruncatedInLength = true ;
return (
line . substring ( 0 , MAX_LINE_LENGTH_TEXT_FILE ) + '... [truncated]'
) ;
}
return line ;
} ) ;
2026-02-20 17:59:18 -05:00
const isTruncated =
actualStart > 0 ||
sliceEnd < originalLineCount ||
linesWereTruncatedInLength ;
2025-08-06 13:52:04 -07:00
const llmContent = formattedLines . join ( '\n' ) ;
2025-05-29 22:30:18 +00:00
2025-07-31 09:31:14 -07:00
// By default, return nothing to streamline the common case of a successful read_file.
let returnDisplay = '' ;
2026-02-20 17:59:18 -05:00
if ( actualStart > 0 || sliceEnd < originalLineCount ) {
2025-07-31 09:31:14 -07:00
returnDisplay = ` Read lines ${
2026-02-20 17:59:18 -05:00
actualStart + 1
} - ${ sliceEnd } of ${ originalLineCount } from ${ relativePathForDisplay } ` ;
2025-07-31 09:31:14 -07:00
if ( linesWereTruncatedInLength ) {
returnDisplay += ' (some lines were shortened)' ;
}
} else if ( linesWereTruncatedInLength ) {
returnDisplay = ` Read all ${ originalLineCount } lines from ${ relativePathForDisplay } (some lines were shortened) ` ;
}
2025-05-29 22:30:18 +00:00
return {
2025-08-06 13:52:04 -07:00
llmContent ,
2025-07-31 09:31:14 -07:00
returnDisplay ,
2025-05-29 22:30:18 +00:00
isTruncated ,
originalLineCount ,
2026-02-20 17:59:18 -05:00
linesShown : [ actualStart + 1 , sliceEnd ] ,
2025-05-29 22:30:18 +00:00
} ;
}
2026-04-11 02:46:24 +05:00
case 'audio' : {
const mimeType = getSupportedAudioMimeTypeForFile ( filePath ) ;
if ( ! mimeType ) {
return {
llmContent : ` Could not read audio file because its format is not supported. Supported audio formats are ${ SUPPORTED_AUDIO_FORMATS_DISPLAY } . ` ,
returnDisplay : ` Unsupported audio file format: ${ relativePathForDisplay } ` ,
error : ` Unsupported audio file format for ${ filePath } . Supported audio formats are ${ SUPPORTED_AUDIO_FORMATS_DISPLAY } . ` ,
errorType : ToolErrorType.READ_CONTENT_FAILURE ,
} ;
}
const contentBuffer = await fs . promises . readFile ( filePath ) ;
const base64Data = contentBuffer . toString ( 'base64' ) ;
return {
llmContent : {
inlineData : {
data : base64Data ,
mimeType ,
} ,
} ,
returnDisplay : ` Read audio file: ${ relativePathForDisplay } ` ,
} ;
}
2025-05-29 22:30:18 +00:00
case 'image' :
2025-07-02 00:52:32 +05:30
case 'pdf' :
case 'video' : {
2026-04-11 02:46:24 +05:00
const mimeType =
getSpecificMimeType ( filePath ) ? ? 'application/octet-stream' ;
2025-05-29 22:30:18 +00:00
const contentBuffer = await fs . promises . readFile ( filePath ) ;
const base64Data = contentBuffer . toString ( 'base64' ) ;
return {
llmContent : {
inlineData : {
data : base64Data ,
2026-04-11 02:46:24 +05:00
mimeType ,
2025-05-29 22:30:18 +00:00
} ,
} ,
returnDisplay : ` Read ${ fileType } file: ${ relativePathForDisplay } ` ,
} ;
}
default : {
// Should not happen with current detectFileType logic
const exhaustiveCheck : never = fileType ;
return {
llmContent : ` Unhandled file type: ${ exhaustiveCheck } ` ,
returnDisplay : ` Skipped unhandled file type: ${ relativePathForDisplay } ` ,
error : ` Unhandled file type for ${ filePath } ` ,
} ;
}
}
} catch ( error ) {
const errorMessage = error instanceof Error ? error.message : String ( error ) ;
const displayPath = path
. relative ( rootDirectory , filePath )
. replace ( /\\/g , '/' ) ;
return {
llmContent : ` Error reading file ${ displayPath } : ${ errorMessage } ` ,
returnDisplay : ` Error reading file ${ displayPath } : ${ errorMessage } ` ,
error : ` Error reading file ${ filePath } : ${ errorMessage } ` ,
2025-08-20 16:13:29 -07:00
errorType : ToolErrorType.READ_CONTENT_FAILURE ,
2025-05-29 22:30:18 +00:00
} ;
}
}
2025-09-08 14:44:56 -07:00
export async function fileExists ( filePath : string ) : Promise < boolean > {
try {
await fsPromises . access ( filePath , fs . constants . F_OK ) ;
return true ;
2026-04-01 21:33:07 -07:00
} catch {
2025-09-08 14:44:56 -07:00
return false ;
}
}
2025-12-26 15:51:39 -05:00
2026-02-05 20:53:11 -05:00
/**
* Sanitizes a string for use as a filename part by removing path traversal
* characters and other non-alphanumeric characters.
*/
export function sanitizeFilenamePart ( part : string ) : string {
return part . replace ( /[^a-zA-Z0-9_-]/g , '_' ) ;
}
2026-01-16 15:57:47 -08:00
/**
2026-02-06 13:41:19 -08:00
* Formats a truncated message for tool output.
* Shows the first 20% and last 80% of the allowed characters with a marker in between.
2026-01-16 15:57:47 -08:00
*/
export function formatTruncatedToolOutput (
contentStr : string ,
outputFile : string ,
2026-02-06 13:41:19 -08:00
maxChars : number ,
2026-01-16 15:57:47 -08:00
) : string {
2026-02-06 13:41:19 -08:00
if ( contentStr . length <= maxChars ) return contentStr ;
2025-12-26 15:51:39 -05:00
2026-02-06 13:41:19 -08:00
const headChars = Math . floor ( maxChars * 0.2 ) ;
const tailChars = maxChars - headChars ;
const head = contentStr . slice ( 0 , headChars ) ;
const tail = contentStr . slice ( - tailChars ) ;
const omittedChars = contentStr . length - headChars - tailChars ;
return ` Output too large. Showing first ${ headChars . toLocaleString ( ) } and last ${ tailChars . toLocaleString ( ) } characters. For full output see: ${ outputFile }
${ head }
... [ ${ omittedChars . toLocaleString ( ) } characters omitted] ...
${ tail } ` ;
2025-12-26 15:51:39 -05:00
}
2026-01-16 15:57:47 -08:00
/**
* Saves tool output to a temporary file for later retrieval.
*/
2026-02-06 01:36:42 -05:00
export const TOOL_OUTPUTS_DIR = 'tool-outputs' ;
2026-01-29 15:20:11 -08:00
2026-01-16 15:57:47 -08:00
export async function saveTruncatedToolOutput (
content : string ,
toolName : string ,
id : string | number , // Accept string (callId) or number (truncationId)
projectTempDir : string ,
2026-02-06 01:36:42 -05:00
sessionId? : string ,
2026-02-06 13:41:19 -08:00
) : Promise < { outputFile : string } > {
2026-02-05 20:53:11 -05:00
const safeToolName = sanitizeFilenamePart ( toolName ) . toLowerCase ( ) ;
const safeId = sanitizeFilenamePart ( id . toString ( ) ) . toLowerCase ( ) ;
2026-02-09 09:09:17 -08:00
const fileName = safeId . startsWith ( safeToolName )
? ` ${ safeId } .txt `
: ` ${ safeToolName } _ ${ safeId } .txt ` ;
2026-02-06 01:36:42 -05:00
let toolOutputDir = path . join ( projectTempDir , TOOL_OUTPUTS_DIR ) ;
if ( sessionId ) {
const safeSessionId = sanitizeFilenamePart ( sessionId ) ;
toolOutputDir = path . join ( toolOutputDir , ` session- ${ safeSessionId } ` ) ;
}
2026-01-29 15:20:11 -08:00
const outputFile = path . join ( toolOutputDir , fileName ) ;
2026-01-16 15:57:47 -08:00
2026-01-29 15:20:11 -08:00
await fsPromises . mkdir ( toolOutputDir , { recursive : true } ) ;
2026-01-16 15:57:47 -08:00
await fsPromises . writeFile ( outputFile , content ) ;
2026-02-06 13:41:19 -08:00
return { outputFile } ;
2026-01-16 15:57:47 -08:00
}