(options: ReadToolOptions)
| 54 | return _enc.encode(s).length; |
| 55 | } |
| 56 | export function createReadTool(options: ReadToolOptions): Tool<z.infer<typeof inputSchema>> { |
| 57 | const { store } = options; |
| 58 | const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; |
| 59 | const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; |
| 60 | |
| 61 | return tool({ |
| 62 | description: `Read the contents of a file. Output is truncated to ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB, whichever is reached first; use offset/limit to page through large files.`, |
| 63 | inputSchema, |
| 64 | execute: async ({ path, offset, limit }): Promise<ReadResult | { error: string }> => { |
| 65 | const stat = await store.stat(path); |
| 66 | if (!stat) return { error: `File not found: ${path}` }; |
| 67 | |
| 68 | const startLine = offset ?? 1; |
| 69 | const wantedLines = limit ?? maxLines; |
| 70 | const lineCap = Math.min(wantedLines, maxLines); |
| 71 | |
| 72 | const decoder = new TextDecoder("utf-8"); |
| 73 | let carry = ""; // bytes from previous chunk that didn't end on a newline |
| 74 | let currentLine = 1; // 1-indexed line we're about to emit |
| 75 | const collected: string[] = []; |
| 76 | let collectedBytes = 0; |
| 77 | let firstEmittedLine: number | null = null; |
| 78 | let truncatedByBudget = false; |
| 79 | let firstLineOverflow = false; |
| 80 | |
| 81 | const processLine = (line: string): boolean => { |
| 82 | // Returns true to keep going, false to stop the outer pump. |
| 83 | if (currentLine < startLine) { |
| 84 | currentLine++; |
| 85 | return true; |
| 86 | } |
| 87 | // We're at or past `startLine` — try to emit. |
| 88 | const lineBytes = utf8ByteLength(line); |
| 89 | if (collected.length === 0 && lineBytes > maxBytes) { |
| 90 | firstLineOverflow = true; |
| 91 | return false; |
| 92 | } |
| 93 | // Stop before emitting if this line would push us over either cap. |
| 94 | if (collected.length >= lineCap) { |
| 95 | truncatedByBudget = true; |
| 96 | return false; |
| 97 | } |
| 98 | if (collectedBytes + lineBytes + (collected.length > 0 ? 1 : 0) > maxBytes) { |
| 99 | truncatedByBudget = true; |
| 100 | return false; |
| 101 | } |
| 102 | if (firstEmittedLine === null) firstEmittedLine = currentLine; |
| 103 | collected.push(line); |
| 104 | collectedBytes += lineBytes + (collected.length > 1 ? 1 : 0); |
| 105 | currentLine++; |
| 106 | return true; |
| 107 | }; |
| 108 | |
| 109 | let keepGoing = true; |
| 110 | for await (const chunk of store.readChunks(path)) { |
| 111 | if (!keepGoing) break; |
| 112 | carry += decoder.decode(chunk, { stream: true }); |
| 113 | // Process every complete line in `carry`. Keep the final partial line |
no test coverage detected