import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs' import { realpath, stat } from 'fs/promises' import { homedir } from 'os' import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep, } from 'path' import { logEvent } from 'src/services/analytics/index.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { getCwd } from '../utils/cwd.js' import { logForDebugging } from './debug.js' import { isENOENT, isFsInaccessible } from './errors.js' import { detectEncodingForResolvedPath, detectLineEndingsForString, type LineEndingType, } from './fileRead.js' import { fileReadCache } from './fileReadCache.js' import { getFsImplementation, safeResolvePath } from './fsOperations.js' import { logError } from './log.js' import { expandPath } from './path.js' import { getPlatform } from './platform.js' export type File = { filename: string content: string } /** * Check if a path exists asynchronously. */ export async function pathExists(path: string): Promise { try { await stat(path) return true } catch { return false } } export const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes export function readFileSafe(filepath: string): string | null { try { const fs = getFsImplementation() return fs.readFileSync(filepath, { encoding: 'utf8' }) } catch (error) { logError(error) return null } } /** * Get the normalized modification time of a file in milliseconds. * Uses Math.floor to ensure consistent timestamp comparisons across file operations, * reducing false positives from sub-millisecond precision changes (e.g., from IDE * file watchers that touch files without changing content). */ export function getFileModificationTime(filePath: string): number { const fs = getFsImplementation() return Math.floor(fs.statSync(filePath).mtimeMs) } /** * Async variant of getFileModificationTime. Same floor semantics. * Use this in async paths (getChangedFiles runs every turn on every readFileState * entry — sync statSync there triggers the slow-operation indicator on network/ * slow disks). */ export async function getFileModificationTimeAsync( filePath: string, ): Promise { const s = await getFsImplementation().stat(filePath) return Math.floor(s.mtimeMs) } export function writeTextContent( filePath: string, content: string, encoding: BufferEncoding, endings: LineEndingType, ): void { let toWrite = content if (endings === 'CRLF') { // Normalize any existing CRLF to LF first so a new_string that already // contains \r\n (raw model output) doesn't become \r\r\n after the join. toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n') } writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding }) } export function detectFileEncoding(filePath: string): BufferEncoding { try { const fs = getFsImplementation() const { resolvedPath } = safeResolvePath(fs, filePath) return detectEncodingForResolvedPath(resolvedPath) } catch (error) { if (isFsInaccessible(error)) { logForDebugging( `detectFileEncoding failed for expected reason: ${error.code}`, { level: 'debug', }, ) } else { logError(error) } return 'utf8' } } export function detectLineEndings( filePath: string, encoding: BufferEncoding = 'utf8', ): LineEndingType { try { const fs = getFsImplementation() const { resolvedPath } = safeResolvePath(fs, filePath) const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 }) const content = buffer.toString(encoding, 0, bytesRead) return detectLineEndingsForString(content) } catch (error) { logError(error) return 'LF' } } export function convertLeadingTabsToSpaces(content: string): string { // The /gm regex scans every line even on no-match; skip it entirely // for the common tab-free case. if (!content.includes('\t')) return content return content.replace(/^\t+/gm, _ => ' '.repeat(_.length)) } export function getAbsoluteAndRelativePaths(path: string | undefined): { absolutePath: string | undefined relativePath: string | undefined } { const absolutePath = path ? expandPath(path) : undefined const relativePath = absolutePath ? relative(getCwd(), absolutePath) : undefined return { absolutePath, relativePath } } export function getDisplayPath(filePath: string): string { // Use relative path if file is in the current working directory const { relativePath } = getAbsoluteAndRelativePaths(filePath) if (relativePath && !relativePath.startsWith('..')) { return relativePath } // Use tilde notation for files in home directory const homeDir = homedir() if (filePath.startsWith(homeDir + sep)) { return '~' + filePath.slice(homeDir.length) } // Otherwise return the absolute path return filePath } /** * Find files with the same name but different extensions in the same directory * @param filePath The path to the file that doesn't exist * @returns The found file with a different extension, or undefined if none found */ export function findSimilarFile(filePath: string): string | undefined { const fs = getFsImplementation() try { const dir = dirname(filePath) const fileBaseName = basename(filePath, extname(filePath)) // Get all files in the directory const files = fs.readdirSync(dir) // Find files with the same base name but different extension const similarFiles = files.filter( file => basename(file.name, extname(file.name)) === fileBaseName && join(dir, file.name) !== filePath, ) // Return just the filename of the first match if found const firstMatch = similarFiles[0] if (firstMatch) { return firstMatch.name } return undefined } catch (error) { // Missing dir (ENOENT) is expected; for other errors log and return undefined if (!isENOENT(error)) { logError(error) } return undefined } } /** * Marker included in file-not-found error messages that contain a cwd note. * UI renderers check for this to show a short "File not found" message. */ export const FILE_NOT_FOUND_CWD_NOTE = 'Note: your current working directory is' /** * Suggests a corrected path under the current working directory when a file/directory * is not found. Detects the "dropped repo folder" pattern where the model constructs * an absolute path missing the repo directory component. * * Example: * cwd = /Users/zeeg/src/currentRepo * requestedPath = /Users/zeeg/src/foobar (doesn't exist) * returns /Users/zeeg/src/currentRepo/foobar (if it exists) * * @param requestedPath - The absolute path that was not found * @returns The corrected path if found under cwd, undefined otherwise */ export async function suggestPathUnderCwd( requestedPath: string, ): Promise { const cwd = getCwd() const cwdParent = dirname(cwd) // Resolve symlinks in the requested path's parent directory (e.g., /tmp -> /private/tmp on macOS) // so the prefix comparison works correctly against the cwd (which is already realpath-resolved). let resolvedPath = requestedPath try { const resolvedDir = await realpath(dirname(requestedPath)) resolvedPath = join(resolvedDir, basename(requestedPath)) } catch { // Parent directory doesn't exist, use the original path } // Only check if the requested path is under cwd's parent but not under cwd itself. // When cwdParent is the root directory (e.g., '/'), use it directly as the prefix // to avoid a double-separator '//' that would never match. const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep if ( !resolvedPath.startsWith(cwdParentPrefix) || resolvedPath.startsWith(cwd + sep) || resolvedPath === cwd ) { return undefined } // Get the relative path from the parent directory const relFromParent = relative(cwdParent, resolvedPath) // Check if the same relative path exists under cwd const correctedPath = join(cwd, relFromParent) try { await stat(correctedPath) return correctedPath } catch { return undefined } } /** * Whether to use the compact line-number prefix format (`N\t` instead of * ` N→`). The padded-arrow format costs 9 bytes/line overhead; at * 1.35B Read calls × 132 lines avg this is 2.18% of fleet uncached input * (bq-queries/read_line_prefix_overhead_verify.sql). * * Ant soak validated no Edit error regression (6.29% vs 6.86% baseline). * Killswitch pattern: GB can disable if issues surface externally. */ export function isCompactLinePrefixEnabled(): boolean { // 3P default: killswitch off = compact format enabled. Client-side only — // no server support needed, safe for Bedrock/Vertex/Foundry. return !getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_compact_line_prefix_killswitch', false, ) } /** * Adds cat -n style line numbers to the content. */ export function addLineNumbers({ content, // 1-indexed startLine, }: { content: string startLine: number }): string { if (!content) { return '' } const lines = content.split(/\r?\n/) if (isCompactLinePrefixEnabled()) { return lines .map((line, index) => `${index + startLine}\t${line}`) .join('\n') } return lines .map((line, index) => { const numStr = String(index + startLine) if (numStr.length >= 6) { return `${numStr}→${line}` } return `${numStr.padStart(6, ' ')}→${line}` }) .join('\n') } /** * Inverse of addLineNumbers — strips the `N→` or `N\t` prefix from a single * line. Co-located so format changes here and in addLineNumbers stay in sync. */ export function stripLineNumberPrefix(line: string): string { const match = line.match(/^\s*\d+[\u2192\t](.*)$/) return match?.[1] ?? line } /** * Checks if a directory is empty. * @param dirPath The path to the directory to check * @returns true if the directory is empty or does not exist, false otherwise */ export function isDirEmpty(dirPath: string): boolean { try { return getFsImplementation().isDirEmptySync(dirPath) } catch (e) { // ENOENT: directory doesn't exist, consider it empty // Other errors (EPERM on macOS protected folders, etc.): assume not empty return isENOENT(e) } } /** * Reads a file with caching to avoid redundant I/O operations. * This is the preferred method for FileEditTool operations. */ export function readFileSyncCached(filePath: string): string { const { content } = fileReadCache.readFile(filePath) return content } /** * Writes to a file and flushes the file to disk * @param filePath The path to the file to write to * @param content The content to write to the file * @param options Options for writing the file, including encoding and mode * @deprecated Use `fs.promises.writeFile` with flush option instead for non-blocking writes. * Sync file writes block the event loop and cause performance issues. */ export function writeFileSyncAndFlush_DEPRECATED( filePath: string, content: string, options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' }, ): void { const fs = getFsImplementation() // Check if the target file is a symlink to preserve it for all users // Note: We don't use safeResolvePath here because we need to manually handle // symlinks to ensure we write to the target while preserving the symlink itself let targetPath = filePath try { // Try to read the symlink - if successful, it's a symlink const linkTarget = fs.readlinkSync(filePath) // Resolve to absolute path targetPath = isAbsolute(linkTarget) ? linkTarget : resolve(dirname(filePath), linkTarget) logForDebugging(`Writing through symlink: ${filePath} -> ${targetPath}`) } catch { // ENOENT (doesn't exist) or EINVAL (not a symlink) — keep targetPath = filePath } // Try atomic write first const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}` // Check if target file exists and get its permissions (single stat, reused in both atomic and fallback paths) let targetMode: number | undefined let targetExists = false try { targetMode = fs.statSync(targetPath).mode targetExists = true logForDebugging(`Preserving file permissions: ${targetMode.toString(8)}`) } catch (e) { if (!isENOENT(e)) throw e if (options.mode !== undefined) { // Use provided mode for new files targetMode = options.mode logForDebugging( `Setting permissions for new file: ${targetMode.toString(8)}`, ) } } try { logForDebugging(`Writing to temp file: ${tempPath}`) // Write to temp file with flush and mode (if specified for new file) const writeOptions: { encoding: BufferEncoding flush: boolean mode?: number } = { encoding: options.encoding, flush: true, } // Only set mode in writeFileSync for new files to ensure atomic permission setting if (!targetExists && options.mode !== undefined) { writeOptions.mode = options.mode } fsWriteFileSync(tempPath, content, writeOptions) logForDebugging( `Temp file written successfully, size: ${content.length} bytes`, ) // For existing files or if mode was not set atomically, apply permissions if (targetExists && targetMode !== undefined) { chmodSync(tempPath, targetMode) logForDebugging(`Applied original permissions to temp file`) } // Atomic rename (on POSIX systems, this is atomic) // On Windows, this will overwrite the destination if it exists logForDebugging(`Renaming ${tempPath} to ${targetPath}`) fs.renameSync(tempPath, targetPath) logForDebugging(`File ${targetPath} written atomically`) } catch (atomicError) { logForDebugging(`Failed to write file atomically: ${atomicError}`, { level: 'error', }) logEvent('tengu_atomic_write_error', {}) // Clean up temp file on error try { logForDebugging(`Cleaning up temp file: ${tempPath}`) fs.unlinkSync(tempPath) } catch (cleanupError) { logForDebugging(`Failed to clean up temp file: ${cleanupError}`) } // Fallback to non-atomic write logForDebugging(`Falling back to non-atomic write for ${targetPath}`) try { const fallbackOptions: { encoding: BufferEncoding flush: boolean mode?: number } = { encoding: options.encoding, flush: true, } // Only set mode for new files if (!targetExists && options.mode !== undefined) { fallbackOptions.mode = options.mode } fsWriteFileSync(targetPath, content, fallbackOptions) logForDebugging( `File ${targetPath} written successfully with non-atomic fallback`, ) } catch (fallbackError) { logForDebugging(`Non-atomic write also failed: ${fallbackError}`) throw fallbackError } } } export function getDesktopPath(): string { const platform = getPlatform() const homeDir = homedir() if (platform === 'macos') { return join(homeDir, 'Desktop') } if (platform === 'windows') { // For WSL, try to access Windows desktop const windowsHome = process.env.USERPROFILE ? process.env.USERPROFILE.replace(/\\/g, '/') : null if (windowsHome) { const wslPath = windowsHome.replace(/^[A-Z]:/, '') const desktopPath = `/mnt/c${wslPath}/Desktop` if (getFsImplementation().existsSync(desktopPath)) { return desktopPath } } // Fallback: try to find desktop in typical Windows user location try { const usersDir = '/mnt/c/Users' const userDirs = getFsImplementation().readdirSync(usersDir) for (const user of userDirs) { if ( user.name === 'Public' || user.name === 'Default' || user.name === 'Default User' || user.name === 'All Users' ) { continue } const potentialDesktopPath = join(usersDir, user.name, 'Desktop') if (getFsImplementation().existsSync(potentialDesktopPath)) { return potentialDesktopPath } } } catch (error) { logError(error) } } // Linux/unknown platform fallback const desktopPath = join(homeDir, 'Desktop') if (getFsImplementation().existsSync(desktopPath)) { return desktopPath } // If Desktop folder doesn't exist, fallback to home directory return homeDir } /** * Validates that a file size is within the specified limit. * Returns true if the file is within the limit, false otherwise. * * @param filePath The path to the file to validate * @param maxSizeBytes The maximum allowed file size in bytes * @returns true if file size is within limit, false otherwise */ export function isFileWithinReadSizeLimit( filePath: string, maxSizeBytes: number = MAX_OUTPUT_SIZE, ): boolean { try { const stats = getFsImplementation().statSync(filePath) return stats.size <= maxSizeBytes } catch { // If we can't stat the file, return false to indicate validation failure return false } } /** * Normalize a file path for comparison, handling platform differences. * On Windows, normalizes path separators and converts to lowercase for * case-insensitive comparison. */ export function normalizePathForComparison(filePath: string): string { // Use path.normalize() to clean up redundant separators and resolve . and .. let normalized = normalize(filePath) // On Windows, normalize for case-insensitive comparison: // - Convert forward slashes to backslashes (path.normalize only does this on actual Windows) // - Convert to lowercase (Windows paths are case-insensitive) if (getPlatform() === 'windows') { normalized = normalized.replace(/\//g, '\\').toLowerCase() } return normalized } /** * Compare two file paths for equality, handling Windows case-insensitivity. */ export function pathsEqual(path1: string, path2: string): boolean { return normalizePathForComparison(path1) === normalizePathForComparison(path2) }