fix(coding-agent): detect image MIME via file-type (#205)

Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
Peter Steinberger 2025-12-17 17:11:56 +01:00 committed by GitHub
parent 46ba48a35d
commit d70edf571e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 191 additions and 50 deletions

View file

@ -2,26 +2,12 @@
* Process @file CLI arguments into text content and image attachments
*/
import { access, readFile, stat } from "node:fs/promises";
import type { Attachment } from "@mariozechner/pi-agent-core";
import chalk from "chalk";
import { existsSync, readFileSync, statSync } from "fs";
import { extname, resolve } from "path";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
/** Map of file extensions to MIME types for common image formats */
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
/** Check if a file is an image based on its extension, returns MIME type or null */
function isImageFile(filePath: string): string | null {
const ext = extname(filePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || null;
}
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
export interface ProcessedFiles {
textContent: string;
@ -29,7 +15,7 @@ export interface ProcessedFiles {
}
/** Process @file arguments into text content and image attachments */
export function processFileArguments(fileArgs: string[]): ProcessedFiles {
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
let textContent = "";
const imageAttachments: Attachment[] = [];
@ -38,23 +24,25 @@ export function processFileArguments(fileArgs: string[]): ProcessedFiles {
const absolutePath = resolve(resolveReadPath(fileArg));
// Check if file exists
if (!existsSync(absolutePath)) {
try {
await access(absolutePath);
} catch {
console.error(chalk.red(`Error: File not found: ${absolutePath}`));
process.exit(1);
}
// Check if file is empty
const stats = statSync(absolutePath);
const stats = await stat(absolutePath);
if (stats.size === 0) {
// Skip empty files
continue;
}
const mimeType = isImageFile(absolutePath);
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
if (mimeType) {
// Handle image file
const content = readFileSync(absolutePath);
const content = await readFile(absolutePath);
const base64Content = content.toString("base64");
const attachment: Attachment = {
@ -73,7 +61,7 @@ export function processFileArguments(fileArgs: string[]): ProcessedFiles {
} else {
// Handle text file
try {
const content = readFileSync(absolutePath, "utf-8");
const content = await readFile(absolutePath, "utf-8");
textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);

View file

@ -2,29 +2,11 @@ import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { extname, resolve as resolvePath } from "path";
import { resolve as resolvePath } from "path";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { resolveReadPath } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Map of file extensions to MIME types for common image formats
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
/**
* Check if a file is an image based on its extension
*/
function isImageFile(filePath: string): string | null {
const ext = extname(filePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || null;
}
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
@ -46,7 +28,6 @@ export const readTool: AgentTool<typeof readSchema> = {
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(resolveReadPath(path));
const mimeType = isImageFile(absolutePath);
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
@ -79,6 +60,8 @@ export const readTool: AgentTool<typeof readSchema> = {
return;
}
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;

View file

@ -115,15 +115,15 @@ async function runInteractiveMode(
}
/** Prepare initial message from @file arguments */
function prepareInitialMessage(parsed: Args): {
async function prepareInitialMessage(parsed: Args): Promise<{
initialMessage?: string;
initialAttachments?: Attachment[];
} {
}> {
if (parsed.fileArgs.length === 0) {
return {};
}
const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs);
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
// Combine file content with first plain text message (if any)
let initialMessage: string;
@ -181,7 +181,7 @@ export async function main(args: string[]) {
}
// Process @file arguments
const { initialMessage, initialAttachments } = prepareInitialMessage(parsed);
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
// Determine if we're in interactive mode (needed for theme watcher)
const isInteractive = !parsed.print && parsed.mode === undefined;

View file

@ -0,0 +1,30 @@
import { open } from "node:fs/promises";
import { fileTypeFromBuffer } from "file-type";
const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const FILE_TYPE_SNIFF_BYTES = 4100;
export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null> {
const fileHandle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
if (bytesRead === 0) {
return null;
}
const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead));
if (!fileType) {
return null;
}
if (!IMAGE_MIME_TYPES.has(fileType.mime)) {
return null;
}
return fileType.mime;
} finally {
await fileHandle.close();
}
}