{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/tools/read.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAI/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAsB9C,QAAA,MAAM,UAAU;;;;;EAKd,CAAC;AAMH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAqH/E","sourcesContent":["import type { AgentTool } from \"@apholdings/jensen-agent-core\";\nimport type { ImageContent, TextContent } from \"@apholdings/jensen-ai\";\nimport { Type } from \"@sinclair/typebox\";\nimport { extname } from \"path\";\nimport type { Executor } from \"../sandbox.js\";\nimport { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from \"./truncate.js\";\n\n/**\n * Map of file extensions to MIME types for common image formats\n */\nconst IMAGE_MIME_TYPES: Record<string, string> = {\n\t\".jpg\": \"image/jpeg\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".png\": \"image/png\",\n\t\".gif\": \"image/gif\",\n\t\".webp\": \"image/webp\",\n};\n\n/**\n * Check if a file is an image based on its extension\n */\nfunction isImageFile(filePath: string): string | null {\n\tconst ext = extname(filePath).toLowerCase();\n\treturn IMAGE_MIME_TYPES[ext] || null;\n}\n\nconst readSchema = Type.Object({\n\tlabel: Type.String({ description: \"Brief description of what you're reading and why (shown to user)\" }),\n\tpath: Type.String({ description: \"Path to the file to read (relative or absolute)\" }),\n\toffset: Type.Optional(Type.Number({ description: \"Line number to start reading from (1-indexed)\" })),\n\tlimit: Type.Optional(Type.Number({ description: \"Maximum number of lines to read\" })),\n});\n\ninterface ReadToolDetails {\n\ttruncation?: TruncationResult;\n}\n\nexport function createReadTool(executor: Executor): AgentTool<typeof readSchema> {\n\treturn {\n\t\tname: \"read\",\n\t\tlabel: \"read\",\n\t\tdescription: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,\n\t\tparameters: readSchema,\n\t\texecute: async (\n\t\t\t_toolCallId: string,\n\t\t\t{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },\n\t\t\tsignal?: AbortSignal,\n\t\t): Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }> => {\n\t\t\tconst mimeType = isImageFile(path);\n\n\t\t\tif (mimeType) {\n\t\t\t\t// Read as image (binary) - use base64\n\t\t\t\tconst result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });\n\t\t\t\tif (result.code !== 0) {\n\t\t\t\t\tthrow new Error(result.stderr || `Failed to read file: ${path}`);\n\t\t\t\t}\n\t\t\t\tconst base64 = result.stdout.replace(/\\s/g, \"\"); // Remove whitespace from base64\n\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: `Read image file [${mimeType}]` },\n\t\t\t\t\t\t{ type: \"image\", data: base64, mimeType },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Get total line count first\n\t\t\tconst countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });\n\t\t\tif (countResult.code !== 0) {\n\t\t\t\tthrow new Error(countResult.stderr || `Failed to read file: ${path}`);\n\t\t\t}\n\t\t\tconst totalFileLines = Number.parseInt(countResult.stdout.trim(), 10) + 1; // wc -l counts newlines, not lines\n\n\t\t\t// Apply offset if specified (1-indexed)\n\t\t\tconst startLine = offset ? Math.max(1, offset) : 1;\n\t\t\tconst startLineDisplay = startLine;\n\n\t\t\t// Check if offset is out of bounds\n\t\t\tif (startLine > totalFileLines) {\n\t\t\t\tthrow new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);\n\t\t\t}\n\n\t\t\t// Read content with offset\n\t\t\tlet cmd: string;\n\t\t\tif (startLine === 1) {\n\t\t\t\tcmd = `cat ${shellEscape(path)}`;\n\t\t\t} else {\n\t\t\t\tcmd = `tail -n +${startLine} ${shellEscape(path)}`;\n\t\t\t}\n\n\t\t\tconst result = await executor.exec(cmd, { signal });\n\t\t\tif (result.code !== 0) {\n\t\t\t\tthrow new Error(result.stderr || `Failed to read file: ${path}`);\n\t\t\t}\n\n\t\t\tlet selectedContent = result.stdout;\n\t\t\tlet userLimitedLines: number | undefined;\n\n\t\t\t// Apply user limit if specified\n\t\t\tif (limit !== undefined) {\n\t\t\t\tconst lines = selectedContent.split(\"\\n\");\n\t\t\t\tconst endLine = Math.min(limit, lines.length);\n\t\t\t\tselectedContent = lines.slice(0, endLine).join(\"\\n\");\n\t\t\t\tuserLimitedLines = endLine;\n\t\t\t}\n\n\t\t\t// Apply truncation (respects both line and byte limits)\n\t\t\tconst truncation = truncateHead(selectedContent);\n\n\t\t\tlet outputText: string;\n\t\t\tlet details: ReadToolDetails | undefined;\n\n\t\t\tif (truncation.firstLineExceedsLimit) {\n\t\t\t\t// First line at offset exceeds 50KB - tell model to use bash\n\t\t\t\tconst firstLineSize = formatSize(Buffer.byteLength(selectedContent.split(\"\\n\")[0], \"utf-8\"));\n\t\t\t\toutputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;\n\t\t\t\tdetails = { truncation };\n\t\t\t} else if (truncation.truncated) {\n\t\t\t\t// Truncation occurred - build actionable notice\n\t\t\t\tconst endLineDisplay = startLineDisplay + truncation.outputLines - 1;\n\t\t\t\tconst nextOffset = endLineDisplay + 1;\n\n\t\t\t\toutputText = truncation.content;\n\n\t\t\t\tif (truncation.truncatedBy === \"lines\") {\n\t\t\t\t\toutputText += `\\n\\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;\n\t\t\t\t} else {\n\t\t\t\t\toutputText += `\\n\\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`;\n\t\t\t\t}\n\t\t\t\tdetails = { truncation };\n\t\t\t} else if (userLimitedLines !== undefined) {\n\t\t\t\t// User specified limit, check if there's more content\n\t\t\t\tconst linesFromStart = startLine - 1 + userLimitedLines;\n\t\t\t\tif (linesFromStart < totalFileLines) {\n\t\t\t\t\tconst remaining = totalFileLines - linesFromStart;\n\t\t\t\t\tconst nextOffset = startLine + userLimitedLines;\n\n\t\t\t\t\toutputText = truncation.content;\n\t\t\t\t\toutputText += `\\n\\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;\n\t\t\t\t} else {\n\t\t\t\t\toutputText = truncation.content;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// No truncation, no user limit exceeded\n\t\t\t\toutputText = truncation.content;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: outputText }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t};\n}\n\nfunction shellEscape(s: string): string {\n\treturn `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n"]}