diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 7ba822dce0..af1dbaa707 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -4,14 +4,19 @@ import * as path from "path" import { countFileLines } from "../../../integrations/misc/line-counter" import { readLines } from "../../../integrations/misc/read-lines" -import { extractTextFromFile } from "../../../integrations/misc/extract-text" +import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter" import { isBinaryFile } from "isbinaryfile" import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools" import { readFileTool } from "../readFileTool" import { formatResponse } from "../../prompts/responses" +import * as contextValidatorModule from "../contextValidator" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB } from "../helpers/imageHelpers" +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + vi.mock("path", async () => { const originalPath = await vi.importActual("path") return { @@ -26,11 +31,14 @@ vi.mock("path", async () => { vi.mock("isbinaryfile") vi.mock("../../../integrations/misc/line-counter") -vi.mock("../../../integrations/misc/read-lines") +vi.mock("../../../integrations/misc/read-lines", () => ({ + readLines: vi.fn().mockResolvedValue("mocked line content"), +})) +vi.mock("../contextValidator") // Mock fs/promises readFile for image tests const fsPromises = vi.hoisted(() => ({ - readFile: vi.fn(), + readFile: vi.fn().mockResolvedValue(Buffer.from("mock file content")), stat: vi.fn().mockResolvedValue({ size: 1024 }), })) vi.mock("fs/promises", () => fsPromises) @@ -115,7 +123,7 @@ vi.mock("../../ignore/RooIgnoreController", () => ({ })) vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockReturnValue(true), + fileExistsAtPath: vi.fn().mockResolvedValue(true), })) // Global beforeEach to ensure clean mock state between all test suites @@ -263,6 +271,12 @@ describe("read_file tool with maxReadFileLine setting", () => { mockedPathResolve.mockReturnValue(absoluteFilePath) mockedIsBinaryFile.mockResolvedValue(false) + // Default mock for validateFileSizeForContext - no limit + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: false, + safeContentLimit: -1, + }) + mockInputContent = fileContent // Setup the extractTextFromFile mock implementation with the current mockInputContent @@ -382,8 +396,7 @@ describe("read_file tool with maxReadFileLine setting", () => { expect(result).toContain(``) // Verify XML structure - expect(result).toContain("Showing only 0 of 5 total lines") - expect(result).toContain("") + expect(result).toContain("tools:readFile.showingOnlyLines") expect(result).toContain("") expect(result).toContain(sourceCodeDef.trim()) expect(result).toContain("") @@ -409,7 +422,7 @@ describe("read_file tool with maxReadFileLine setting", () => { expect(result).toContain(`${testFilePath}`) expect(result).toContain(``) expect(result).toContain(``) - expect(result).toContain("Showing only 3 of 5 total lines") + expect(result).toContain("tools:readFile.showingOnlyLines") }) }) @@ -523,6 +536,7 @@ describe("read_file tool XML output structure", () => { mockedPathResolve.mockReturnValue(absoluteFilePath) mockedIsBinaryFile.mockResolvedValue(false) + mockedCountFileLines.mockResolvedValue(5) // Default line count // Set default implementation for extractTextFromFile mockedExtractTextFromFile.mockImplementation((filePath) => { @@ -1326,6 +1340,144 @@ describe("read_file tool XML output structure", () => { ) }) }) + + describe("line range instructions", () => { + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + + // Mock file system functions + vi.mocked(isBinaryFile).mockResolvedValue(false) + vi.mocked(countFileLines).mockResolvedValue(10000) // Large file + vi.mocked(readLines).mockResolvedValue("line content") + vi.mocked(extractTextFromFile).mockResolvedValue("file content") + + // Mock addLineNumbers + vi.mocked(addLineNumbers).mockImplementation((content, start) => `${start || 1} | ${content}`) + }) + + it("should always include inline line_range instructions when shouldLimit is true", async () => { + // Mock a large file + vi.mocked(countFileLines).mockResolvedValue(10000) + + // Mock contextValidator to return shouldLimit true + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: true, + safeContentLimit: 2000, + reason: "This is a partial read - the remaining content cannot be accessed due to context limitations.", + }) + + // Mock readLines to return truncated content with maxChars + vi.mocked(readLines).mockResolvedValue("Line 1\nLine 2\n...truncated...") + + const result = await executeReadFileTool( + { args: `large-file.ts` }, + { totalLines: 10000, maxReadFileLine: -1 }, + ) + + // Verify the result contains the simplified partial read notice + expect(result).toContain("") + expect(result).toContain( + "This is a partial read - the remaining content cannot be accessed due to context limitations.", + ) + }) + + it("should not show any special notice when file fits in context", async () => { + // Mock small file that fits in context + vi.mocked(countFileLines).mockResolvedValue(100) + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: false, + safeContentLimit: -1, + }) + + const result = await executeReadFileTool({ args: `small-file.ts` }) + + // Should have file content but no notice about limits + expect(result).toContain("") + expect(result).toContain("small-file.ts") + expect(result).toContain(" { + // Mock a single-line file that exceeds context + vi.mocked(countFileLines).mockResolvedValue(1) + + // Mock contextValidator to return shouldLimit true with single-line file message + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: true, + safeContentLimit: 5000, + reason: "This is a partial read - the remaining content cannot be accessed due to context limitations.", + }) + + // Mock readLines to return truncated content for single-line file with maxChars + vi.mocked(readLines).mockResolvedValue("const a=1;const b=2;...truncated") + + const result = await executeReadFileTool( + { args: `minified.js` }, + { totalLines: 1, maxReadFileLine: -1 }, + ) + + // Verify the result contains the simplified partial read notice + expect(result).toContain("") + expect(result).toContain( + "This is a partial read - the remaining content cannot be accessed due to context limitations.", + ) + }) + + it("should include line_range instructions for multi-line files that exceed context", async () => { + // Mock a multi-line file that exceeds context + vi.mocked(countFileLines).mockResolvedValue(5000) + + // Mock contextValidator to return shouldLimit true with multi-line file message + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: true, + safeContentLimit: 50000, + reason: "This is a partial read - the remaining content cannot be accessed due to context limitations.", + }) + + // Mock readLines to return truncated content with maxChars + vi.mocked(readLines).mockResolvedValue("Line 1\nLine 2\n...truncated...") + + const result = await executeReadFileTool( + { args: `large-file.ts` }, + { totalLines: 5000, maxReadFileLine: -1 }, + ) + + // Verify the result contains the simplified partial read notice + expect(result).toContain("") + expect(result).toContain( + "This is a partial read - the remaining content cannot be accessed due to context limitations.", + ) + }) + + it("should handle normal file read section for single-line files with validation notice", async () => { + // Mock a single-line file that has shouldLimit true but fits after truncation + vi.mocked(countFileLines).mockResolvedValue(1) + + // Mock contextValidator to return shouldLimit true with a single-line file notice + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: true, + safeContentLimit: 8000, + reason: "This is a partial read - the remaining content cannot be accessed due to context limitations.", + }) + + // Mock readLines for single-line file with maxChars + vi.mocked(readLines).mockResolvedValue("const a=1;const b=2;const c=3;") + + const result = await executeReadFileTool( + { args: `semi-large.js` }, + { totalLines: 1, maxReadFileLine: -1 }, + ) + + // Verify the result contains the simplified partial read notice + expect(result).toContain("") + expect(result).toContain( + "This is a partial read - the remaining content cannot be accessed due to context limitations.", + ) + }) + }) }) describe("read_file tool with image support", () => { @@ -1591,12 +1743,24 @@ describe("read_file tool with image support", () => { mockedPathResolve.mockReturnValue(absolutePath) mockedExtractTextFromFile.mockResolvedValue("PDF content extracted") + // Ensure the file is treated as binary and PDF is in supported formats + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx", ".ipynb"]) + + // Mock contextValidator to not interfere with PDF processing + vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({ + shouldLimit: false, + safeContentLimit: -1, + }) + // Execute const result = await executeReadImageTool(binaryPath) - // Verify it uses extractTextFromFile instead + // Verify it doesn't treat the PDF as an image expect(result).not.toContain("") - // Make the test platform-agnostic by checking the call was made (path normalization can vary) + + // Should call extractTextFromFile for PDF processing expect(mockedExtractTextFromFile).toHaveBeenCalledTimes(1) const callArgs = mockedExtractTextFromFile.mock.calls[0] expect(callArgs[0]).toMatch(/[\\\/]test[\\\/]document\.pdf$/) diff --git a/src/core/tools/contextValidator.ts b/src/core/tools/contextValidator.ts new file mode 100644 index 0000000000..7305b104a1 --- /dev/null +++ b/src/core/tools/contextValidator.ts @@ -0,0 +1,100 @@ +import { Task } from "../task/Task" +import * as fs from "fs/promises" + +/** + * Conservative buffer percentage for file reading. + * We use a very conservative estimate to ensure files fit in context. + */ +const FILE_READ_BUFFER_PERCENTAGE = 0.4 // 40% buffer for safety + +/** + * Very conservative character to token ratio + * Using 2.5 chars per token instead of 3-4 to be extra safe + */ +const CHARS_PER_TOKEN_CONSERVATIVE = 2.5 + +/** + * File size thresholds + */ +const TINY_FILE_SIZE = 10 * 1024 // 10KB - always safe +const SMALL_FILE_SIZE = 50 * 1024 // 50KB - safe if context is mostly empty +const MEDIUM_FILE_SIZE = 500 * 1024 // 500KB - needs validation +const LARGE_FILE_SIZE = 1024 * 1024 // 1MB - always limit + +export interface ContextValidationResult { + shouldLimit: boolean + safeContentLimit: number // Character count limit + reason?: string +} + +/** + * Simple validation based on file size and available context. + * Uses very conservative estimates to avoid context overflow. + */ +export async function validateFileSizeForContext( + filePath: string, + totalLines: number, + currentMaxReadFileLine: number, + cline: Task, +): Promise { + try { + // Get file size + const stats = await fs.stat(filePath) + const fileSizeBytes = stats.size + + // Tiny files are always safe + if (fileSizeBytes < TINY_FILE_SIZE) { + return { shouldLimit: false, safeContentLimit: -1 } + } + + // Get context information + const modelInfo = cline.api.getModel().info + const { contextTokens: currentContextTokens } = cline.getTokenUsage() + const contextWindow = modelInfo.contextWindow + const currentlyUsed = currentContextTokens || 0 + + // Calculate available space with conservative buffer + const remainingTokens = contextWindow - currentlyUsed + const usableTokens = Math.floor(remainingTokens * (1 - FILE_READ_BUFFER_PERCENTAGE)) + + // Reserve space for response (use 25% of remaining or 4096, whichever is smaller) + const responseReserve = Math.min(Math.floor(usableTokens * 0.25), 4096) + const availableForFile = usableTokens - responseReserve + + // Convert to conservative character estimate + const safeCharLimit = Math.floor(availableForFile * CHARS_PER_TOKEN_CONSERVATIVE) + + // For small files with mostly empty context, allow full read + const contextUsagePercent = currentlyUsed / contextWindow + if (fileSizeBytes < SMALL_FILE_SIZE && contextUsagePercent < 0.3) { + return { shouldLimit: false, safeContentLimit: -1 } + } + + // For medium files, check if they fit within safe limit + if (fileSizeBytes < MEDIUM_FILE_SIZE && fileSizeBytes <= safeCharLimit) { + return { shouldLimit: false, safeContentLimit: -1 } + } + + // For large files or when approaching limits, always limit + if (fileSizeBytes > safeCharLimit || fileSizeBytes > LARGE_FILE_SIZE) { + // Use a very conservative limit + const finalLimit = Math.min(safeCharLimit, 100000) // Cap at 100K chars + + return { + shouldLimit: true, + safeContentLimit: finalLimit, + reason: "This is a partial read - the remaining content cannot be accessed due to context limitations.", + } + } + + return { shouldLimit: false, safeContentLimit: -1 } + } catch (error) { + // On any error, use ultra-conservative defaults + console.warn(`[validateFileSizeForContext] Error during validation: ${error}`) + return { + shouldLimit: true, + safeContentLimit: 50000, // 50K chars as safe fallback + reason: "This is a partial read - the remaining content cannot be accessed due to context limitations.", + } + } +} diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 01427f4d9d..946ee4bf41 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -2,6 +2,7 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" import { Task } from "../task/Task" +import { validateFileSizeForContext } from "./contextValidator" import { ClineSayTool } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" @@ -456,6 +457,13 @@ export async function readFileTool( try { const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) + // Preemptive file size validation to prevent context overflow + const validation = await validateFileSizeForContext(fullPath, totalLines, maxReadFileLine, cline) + let validationNotice = "" + + // Apply validation if maxReadFileLine is -1 (unlimited) + const shouldApplyValidation = validation.shouldLimit && maxReadFileLine === -1 + // Handle binary files (but allow specific file types that extractTextFromFile can handle) if (isBinary) { const fileExtension = path.extname(relPath).toLowerCase() @@ -550,7 +558,7 @@ export async function readFileTool( try { const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) if (defResult) { - let xmlInfo = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` + let xmlInfo = `${t("tools:readFile.showingOnlyLines", { shown: 0, total: totalLines })}\n` updateFileResult(relPath, { xmlContent: `${relPath}\n${defResult}\n${xmlInfo}`, }) @@ -567,7 +575,31 @@ export async function readFileTool( continue } - // Handle files exceeding line threshold + // Handle files with validation limits (character-based reading) + if (shouldApplyValidation) { + const partialContent = await readLines(fullPath, undefined, undefined, validation.safeContentLimit) + + // Count lines in the partial content + const linesRead = partialContent ? (partialContent.match(/\n/g) || []).length + 1 : 0 + + // Generate line range attribute based on what was read + const lineRangeAttr = linesRead === 1 ? ` lines="1"` : ` lines="1-${linesRead}"` + + const content = addLineNumbers(partialContent, 1) + let xmlInfo = `\n${content}\n` + + // Add simple notice about partial read + if (validation.reason) { + xmlInfo += `${validation.reason}\n` + } + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${xmlInfo}`, + }) + continue + } + + // Handle files with line limits (maxReadFileLine > 0) if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) const lineRangeAttr = ` lines="1-${maxReadFileLine}"` @@ -578,7 +610,8 @@ export async function readFileTool( if (defResult) { xmlInfo += `${defResult}\n` } - xmlInfo += `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` + xmlInfo += `${t("tools:readFile.showingOnlyLines", { shown: maxReadFileLine, total: totalLines })}\n` + updateFileResult(relPath, { xmlContent: `${relPath}\n${xmlInfo}`, }) @@ -594,8 +627,9 @@ export async function readFileTool( continue } - // Handle normal file read + // Handle normal file read (no limits) const content = await extractTextFromFile(fullPath) + const lineRangeAttr = ` lines="1-${totalLines}"` let xmlInfo = totalLines > 0 ? `\n${content}\n` : `` diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 0f10b6fc2a..07a8c97bb1 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -3,6 +3,7 @@ "linesRange": " (línies {{start}}-{{end}})", "definitionsOnly": " (només definicions)", "maxLines": " (màxim {{max}} línies)", + "showingOnlyLines": "Mostrant només {{shown}} de {{total}} línies totals. Utilitza line_range si necessites llegir més línies", "imageTooLarge": "El fitxer d'imatge és massa gran ({{size}} MB). La mida màxima permesa és {{max}} MB.", "imageWithSize": "Fitxer d'imatge ({{size}} KB)" }, diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index ecf372a50b..2e633a25d6 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -3,6 +3,7 @@ "linesRange": " (Zeilen {{start}}-{{end}})", "definitionsOnly": " (nur Definitionen)", "maxLines": " (maximal {{max}} Zeilen)", + "showingOnlyLines": "Zeige nur {{shown}} von {{total}} Zeilen insgesamt. Verwende line_range, wenn du mehr Zeilen lesen musst", "imageTooLarge": "Die Bilddatei ist zu groß ({{size}} MB). Die maximal erlaubte Größe beträgt {{max}} MB.", "imageWithSize": "Bilddatei ({{size}} KB)" }, diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index 5b88affae6..35be03589b 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -3,6 +3,7 @@ "linesRange": " (lines {{start}}-{{end}})", "definitionsOnly": " (definitions only)", "maxLines": " (max {{max}} lines)", + "showingOnlyLines": "Showing only {{shown}} of {{total}} total lines. Use line_range if you need to read more lines", "imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", "imageWithSize": "Image file ({{size}} KB)" }, diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index 6fd1cc2122..61e216061f 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -3,6 +3,7 @@ "linesRange": " (líneas {{start}}-{{end}})", "definitionsOnly": " (solo definiciones)", "maxLines": " (máximo {{max}} líneas)", + "showingOnlyLines": "Mostrando solo {{shown}} de {{total}} líneas totales. Usa line_range si necesitas leer más líneas", "imageTooLarge": "El archivo de imagen es demasiado grande ({{size}} MB). El tamaño máximo permitido es {{max}} MB.", "imageWithSize": "Archivo de imagen ({{size}} KB)" }, diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index b6d7accebb..568497e402 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -3,6 +3,7 @@ "linesRange": " (lignes {{start}}-{{end}})", "definitionsOnly": " (définitions uniquement)", "maxLines": " (max {{max}} lignes)", + "showingOnlyLines": "Affichage de seulement {{shown}} sur {{total}} lignes totales. Utilise line_range si tu as besoin de lire plus de lignes", "imageTooLarge": "Le fichier image est trop volumineux ({{size}} MB). La taille maximale autorisée est {{max}} MB.", "imageWithSize": "Fichier image ({{size}} Ko)" }, diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index cbfbd7aef7..7970b36474 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -3,6 +3,7 @@ "linesRange": " (पंक्तियाँ {{start}}-{{end}})", "definitionsOnly": " (केवल परिभाषाएँ)", "maxLines": " (अधिकतम {{max}} पंक्तियाँ)", + "showingOnlyLines": "कुल {{total}} पंक्तियों में से केवल {{shown}} दिखा रहे हैं। यदि आपको अधिक पंक्तियाँ पढ़नी हैं तो line_range का उपयोग करें।", "imageTooLarge": "छवि फ़ाइल बहुत बड़ी है ({{size}} MB)। अधिकतम अनुमतित आकार {{max}} MB है।", "imageWithSize": "छवि फ़ाइल ({{size}} KB)" }, diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 3eb8854eff..6b2f666d33 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -3,6 +3,7 @@ "linesRange": " (baris {{start}}-{{end}})", "definitionsOnly": " (hanya definisi)", "maxLines": " (maks {{max}} baris)", + "showingOnlyLines": "Menampilkan hanya {{shown}} dari {{total}} total baris. Gunakan line_range jika kamu perlu membaca lebih banyak baris", "imageTooLarge": "File gambar terlalu besar ({{size}} MB). Ukuran maksimum yang diizinkan adalah {{max}} MB.", "imageWithSize": "File gambar ({{size}} KB)" }, diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index 35b114a719..dbf399ece4 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -3,6 +3,7 @@ "linesRange": " (righe {{start}}-{{end}})", "definitionsOnly": " (solo definizioni)", "maxLines": " (max {{max}} righe)", + "showingOnlyLines": "Mostrando solo {{shown}} di {{total}} righe totali. Usa line_range se hai bisogno di leggere più righe", "imageTooLarge": "Il file immagine è troppo grande ({{size}} MB). La dimensione massima consentita è {{max}} MB.", "imageWithSize": "File immagine ({{size}} KB)" }, diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index 257d5aa201..17bce7e089 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -3,6 +3,7 @@ "linesRange": " ({{start}}-{{end}}行目)", "definitionsOnly": " (定義のみ)", "maxLines": " (最大{{max}}行)", + "showingOnlyLines": "全{{total}}行中{{shown}}行のみ表示しています。より多くの行を読む必要がある場合はline_rangeを使用してください", "imageTooLarge": "画像ファイルが大きすぎます({{size}} MB)。最大許可サイズは {{max}} MB です。", "imageWithSize": "画像ファイル({{size}} KB)" }, diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index 94b6d8c377..ca179ea541 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -3,6 +3,7 @@ "linesRange": " ({{start}}-{{end}}행)", "definitionsOnly": " (정의만)", "maxLines": " (최대 {{max}}행)", + "showingOnlyLines": "전체 {{total}}행 중 {{shown}}행만 표시하고 있습니다. 더 많은 행을 읽으려면 line_range를 사용하세요", "imageTooLarge": "이미지 파일이 너무 큽니다 ({{size}} MB). 최대 허용 크기는 {{max}} MB입니다.", "imageWithSize": "이미지 파일 ({{size}} KB)" }, diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index 449cd54583..f6c244595e 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -3,6 +3,7 @@ "linesRange": " (regels {{start}}-{{end}})", "definitionsOnly": " (alleen definities)", "maxLines": " (max {{max}} regels)", + "showingOnlyLines": "Toont alleen {{shown}} van {{total}} totale regels. Gebruik line_range als je meer regels wilt lezen", "imageTooLarge": "Afbeeldingsbestand is te groot ({{size}} MB). De maximaal toegestane grootte is {{max}} MB.", "imageWithSize": "Afbeeldingsbestand ({{size}} KB)" }, diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index 979b2f54ae..a23da234bd 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -3,6 +3,7 @@ "linesRange": " (linie {{start}}-{{end}})", "definitionsOnly": " (tylko definicje)", "maxLines": " (maks. {{max}} linii)", + "showingOnlyLines": "Pokazuję tylko {{shown}} z {{total}} wszystkich linii. Użyj line_range jeśli potrzebujesz przeczytać więcej linii", "imageTooLarge": "Plik obrazu jest zbyt duży ({{size}} MB). Maksymalny dozwolony rozmiar to {{max}} MB.", "imageWithSize": "Plik obrazu ({{size}} KB)" }, diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 4e3296fd4a..0a419a7189 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -3,6 +3,7 @@ "linesRange": " (linhas {{start}}-{{end}})", "definitionsOnly": " (apenas definições)", "maxLines": " (máx. {{max}} linhas)", + "showingOnlyLines": "Mostrando apenas {{shown}} de {{total}} linhas totais. Use line_range se precisar ler mais linhas", "imageTooLarge": "Arquivo de imagem é muito grande ({{size}} MB). O tamanho máximo permitido é {{max}} MB.", "imageWithSize": "Arquivo de imagem ({{size}} KB)" }, diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index d74918f058..17e48706c1 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -3,6 +3,7 @@ "linesRange": " (строки {{start}}-{{end}})", "definitionsOnly": " (только определения)", "maxLines": " (макс. {{max}} строк)", + "showingOnlyLines": "Показано только {{shown}} из {{total}} общих строк. Используй line_range если нужно прочитать больше строк", "imageTooLarge": "Файл изображения слишком большой ({{size}} МБ). Максимально допустимый размер {{max}} МБ.", "imageWithSize": "Файл изображения ({{size}} КБ)" }, diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index 5341a23cb1..abf14bd256 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -3,6 +3,7 @@ "linesRange": " (satır {{start}}-{{end}})", "definitionsOnly": " (sadece tanımlar)", "maxLines": " (maks. {{max}} satır)", + "showingOnlyLines": "Toplam {{total}} satırdan sadece {{shown}} tanesi gösteriliyor. Daha fazla satır okumak için line_range kullan", "imageTooLarge": "Görüntü dosyası çok büyük ({{size}} MB). İzin verilen maksimum boyut {{max}} MB.", "imageWithSize": "Görüntü dosyası ({{size}} KB)" }, diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 4c5080a146..9c4d4e17d4 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -3,6 +3,7 @@ "linesRange": " (dòng {{start}}-{{end}})", "definitionsOnly": " (chỉ định nghĩa)", "maxLines": " (tối đa {{max}} dòng)", + "showingOnlyLines": "Chỉ hiển thị {{shown}} trong tổng số {{total}} dòng. Sử dụng line_range nếu bạn cần đọc thêm dòng", "imageTooLarge": "Tệp hình ảnh quá lớn ({{size}} MB). Kích thước tối đa cho phép là {{max}} MB.", "imageWithSize": "Tệp hình ảnh ({{size}} KB)" }, diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index c0c93d8436..e6b4189414 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -3,6 +3,7 @@ "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (仅定义)", "maxLines": " (最多 {{max}} 行)", + "showingOnlyLines": "仅显示 {{shown}} 行,共 {{total}} 行。如需阅读更多行请使用 line_range", "imageTooLarge": "图片文件过大 ({{size}} MB)。允许的最大大小为 {{max}} MB。", "imageWithSize": "图片文件 ({{size}} KB)" }, diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index b736448c20..436c315ddd 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -3,6 +3,7 @@ "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (僅定義)", "maxLines": " (最多 {{max}} 行)", + "showingOnlyLines": "僅顯示 {{shown}} 行,共 {{total}} 行。如需閱讀更多行請使用 line_range", "imageTooLarge": "圖片檔案過大 ({{size}} MB)。允許的最大大小為 {{max}} MB。", "imageWithSize": "圖片檔案 ({{size}} KB)" }, diff --git a/src/integrations/misc/__tests__/read-lines.spec.ts b/src/integrations/misc/__tests__/read-lines.spec.ts index 14456d24f1..01130031ee 100644 --- a/src/integrations/misc/__tests__/read-lines.spec.ts +++ b/src/integrations/misc/__tests__/read-lines.spec.ts @@ -128,5 +128,79 @@ describe("nthline", () => { expect(lines).toEqual("\n\n\n") }) }) + + describe("maxChars parameter", () => { + it("should limit output to maxChars when reading entire file", async () => { + const content = await readLines(testFile, undefined, undefined, 20) + expect(content).toEqual("Line 1\nLine 2\nLine 3") + expect(content.length).toBe(20) + }) + + it("should limit output to maxChars when reading a range", async () => { + const content = await readLines(testFile, 5, 1, 15) + // When maxChars cuts off in the middle of a line, we get partial content + expect(content).toEqual("Line 2\nLine 3\nL") + expect(content.length).toBe(15) + }) + + it("should return empty string when maxChars is 0", async () => { + const content = await readLines(testFile, undefined, undefined, 0) + expect(content).toEqual("") + }) + + it("should handle maxChars smaller than first line", async () => { + const content = await readLines(testFile, undefined, undefined, 3) + expect(content).toEqual("Lin") + expect(content.length).toBe(3) + }) + + it("should handle maxChars that cuts off in the middle of a line", async () => { + const content = await readLines(testFile, 2, 0, 10) + expect(content).toEqual("Line 1\nLin") + expect(content.length).toBe(10) + }) + + it("should respect both line limits and maxChars", async () => { + // This should read lines 2-4, but stop at 25 chars + const content = await readLines(testFile, 3, 1, 25) + expect(content).toEqual("Line 2\nLine 3\nLine 4\n") + expect(content.length).toBeLessThanOrEqual(25) + }) + + it("should handle maxChars with single line file", async () => { + await withTempFile( + "single-line-maxchars.txt", + "This is a long single line of text", + async (filepath) => { + const content = await readLines(filepath, undefined, undefined, 10) + expect(content).toEqual("This is a ") + expect(content.length).toBe(10) + }, + ) + }) + + it("should handle maxChars with Unicode characters", async () => { + await withTempFile("unicode-maxchars.txt", "Hello 😀 World\nLine 2", async (filepath) => { + // Note: The emoji counts as 2 chars in JavaScript strings + const content = await readLines(filepath, undefined, undefined, 10) + expect(content).toEqual("Hello 😀 W") + expect(content.length).toBe(10) + }) + }) + + it("should handle maxChars larger than file size", async () => { + const content = await readLines(testFile, undefined, undefined, 1000) + const fullContent = await readLines(testFile) + expect(content).toEqual(fullContent) + }) + + it("should handle maxChars with empty lines", async () => { + await withTempFile("empty-lines-maxchars.txt", "Line 1\n\n\nLine 4\n", async (filepath) => { + const content = await readLines(filepath, undefined, undefined, 10) + expect(content).toEqual("Line 1\n\n\nL") + expect(content.length).toBe(10) + }) + }) + }) }) }) diff --git a/src/integrations/misc/read-lines.ts b/src/integrations/misc/read-lines.ts index 5a5eda9f83..1893f162bc 100644 --- a/src/integrations/misc/read-lines.ts +++ b/src/integrations/misc/read-lines.ts @@ -18,10 +18,11 @@ const outOfRangeError = (filepath: string, n: number) => { * @param filepath - Path to the file to read * @param endLine - Optional. The line number to stop reading at (inclusive). If undefined, reads to the end of file. * @param startLine - Optional. The line number to start reading from (inclusive). If undefined, starts from line 0. + * @param maxChars - Optional. Maximum number of characters to read. If specified, reading stops when this limit is reached. * @returns Promise resolving to a string containing the read lines joined with newlines * @throws {RangeError} If line numbers are invalid or out of range */ -export function readLines(filepath: string, endLine?: number, startLine?: number): Promise { +export function readLines(filepath: string, endLine?: number, startLine?: number, maxChars?: number): Promise { return new Promise((resolve, reject) => { // Reject if startLine is defined but not a number if (startLine !== undefined && typeof startLine !== "number") { @@ -52,11 +53,16 @@ export function readLines(filepath: string, endLine?: number, startLine?: number ) } - // Set up stream - const input = createReadStream(filepath) + // Set up stream - only add 'end' option when maxChars is specified to avoid reading entire file + const streamOptions = + maxChars !== undefined + ? { end: Math.min(maxChars * 2, maxChars + 1024 * 1024) } // Read at most 2x maxChars or maxChars + 1MB + : undefined + const input = createReadStream(filepath, streamOptions) let buffer = "" let lineCount = 0 let result = "" + let totalCharsRead = 0 // Handle errors input.on("error", reject) @@ -73,7 +79,24 @@ export function readLines(filepath: string, endLine?: number, startLine?: number while (nextNewline !== -1) { // If we're in the target range, add this line to the result if (lineCount >= effectiveStartLine && (endLine === undefined || lineCount <= endLine)) { - result += buffer.substring(pos, nextNewline + 1) // Include the newline + const lineToAdd = buffer.substring(pos, nextNewline + 1) // Include the newline + + // Check if adding this line would exceed maxChars (only if maxChars is specified) + if (maxChars !== undefined && totalCharsRead + lineToAdd.length > maxChars) { + // Add only the portion that fits within maxChars + const remainingChars = maxChars - totalCharsRead + if (remainingChars > 0) { + result += lineToAdd.substring(0, remainingChars) + } + input.destroy() + resolve(result) + return + } + + result += lineToAdd + if (maxChars !== undefined) { + totalCharsRead += lineToAdd.length + } } // Move position and increment line counter @@ -100,7 +123,15 @@ export function readLines(filepath: string, endLine?: number, startLine?: number // Process any remaining data in buffer (last line without newline) if (buffer.length > 0) { if (lineCount >= effectiveStartLine && (endLine === undefined || lineCount <= endLine)) { - result += buffer + // Check if adding this would exceed maxChars (only if maxChars is specified) + if (maxChars !== undefined && totalCharsRead + buffer.length > maxChars) { + const remainingChars = maxChars - totalCharsRead + if (remainingChars > 0) { + result += buffer.substring(0, remainingChars) + } + } else { + result += buffer + } } lineCount++ }