mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
fix(coding-agent): detect image MIME via file-type (#205)
Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
parent
46ba48a35d
commit
d70edf571e
8 changed files with 191 additions and 50 deletions
106
package-lock.json
generated
106
package-lock.json
generated
|
|
@ -248,6 +248,16 @@
|
|||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz",
|
||||
"integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
|
|
@ -2141,6 +2151,29 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"token-types": "^6.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -3371,6 +3404,24 @@
|
|||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz",
|
||||
"integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.4.1",
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.1",
|
||||
"uint8array-extras": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -3729,6 +3780,7 @@
|
|||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
|
||||
"integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
|
|
@ -3772,7 +3824,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -4299,6 +4350,7 @@
|
|||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
|
||||
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
|
|
@ -5342,6 +5394,22 @@
|
|||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.3.4",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
|
||||
"integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
|
|
@ -5363,6 +5431,7 @@
|
|||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
|
|
@ -5391,7 +5460,8 @@
|
|||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
|
|
@ -5509,6 +5579,24 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz",
|
||||
"integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.1.0",
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
|
|
@ -5588,6 +5676,18 @@
|
|||
"@webreflection/alien-signals": "^0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
|
@ -5605,6 +5705,7 @@
|
|||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -6147,6 +6248,7 @@
|
|||
"@mariozechner/pi-tui": "^0.23.1",
|
||||
"chalk": "^5.5.0",
|
||||
"diff": "^8.0.2",
|
||||
"file-type": "^21.1.1",
|
||||
"glob": "^11.0.3",
|
||||
"jiti": "^2.6.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Detect image MIME type via file magic (read tool and `@file` attachments), not filename extension.
|
||||
|
||||
## [0.23.1] - 2025-12-17
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
"@mariozechner/pi-tui": "^0.23.1",
|
||||
"chalk": "^5.5.0",
|
||||
"diff": "^8.0.2",
|
||||
"file-type": "^21.1.1",
|
||||
"glob": "^11.0.3",
|
||||
"jiti": "^2.6.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
30
packages/coding-agent/src/utils/mime.ts
Normal file
30
packages/coding-agent/src/utils/mime.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,39 @@ describe("Coding Agent Tools", () => {
|
|||
expect(result.details?.truncation?.totalLines).toBe(2500);
|
||||
expect(result.details?.truncation?.outputLines).toBe(2000);
|
||||
});
|
||||
|
||||
it("should detect image MIME type from file magic (not extension)", async () => {
|
||||
const png1x1Base64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII=";
|
||||
const pngBuffer = Buffer.from(png1x1Base64, "base64");
|
||||
|
||||
const testFile = join(testDir, "image.txt");
|
||||
writeFileSync(testFile, pngBuffer);
|
||||
|
||||
const result = await readTool.execute("test-call-img-1", { path: testFile });
|
||||
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
expect(getTextOutput(result)).toContain("Read image file [image/png]");
|
||||
|
||||
const imageBlock = result.content.find(
|
||||
(c): c is { type: "image"; mimeType: string; data: string } => c.type === "image",
|
||||
);
|
||||
expect(imageBlock).toBeDefined();
|
||||
expect(imageBlock?.mimeType).toBe("image/png");
|
||||
expect(typeof imageBlock?.data).toBe("string");
|
||||
expect((imageBlock?.data ?? "").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should treat files with image extension but non-image content as text", async () => {
|
||||
const testFile = join(testDir, "not-an-image.png");
|
||||
writeFileSync(testFile, "definitely not a png");
|
||||
|
||||
const result = await readTool.execute("test-call-img-2", { path: testFile });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(output).toContain("definitely not a png");
|
||||
expect(result.content.some((c: any) => c.type === "image")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write tool", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue