mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 10:05:14 +00:00
196 lines
5.3 KiB
TypeScript
196 lines
5.3 KiB
TypeScript
import * as os from "node:os";
|
|
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";
|
|
|
|
/**
|
|
* Expand ~ to home directory
|
|
*/
|
|
function expandPath(filePath: string): string {
|
|
if (filePath === "~") {
|
|
return os.homedir();
|
|
}
|
|
if (filePath.startsWith("~/")) {
|
|
return os.homedir() + filePath.slice(1);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
/**
|
|
* 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)" })),
|
|
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
|
});
|
|
|
|
const MAX_LINES = 2000;
|
|
const MAX_LINE_LENGTH = 2000;
|
|
|
|
export const readTool: AgentTool<typeof readSchema> = {
|
|
name: "read",
|
|
label: "read",
|
|
description:
|
|
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
|
parameters: readSchema,
|
|
execute: async (
|
|
_toolCallId: string,
|
|
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
|
|
signal?: AbortSignal,
|
|
) => {
|
|
const absolutePath = resolvePath(expandPath(path));
|
|
const mimeType = isImageFile(absolutePath);
|
|
|
|
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
|
|
// Check if already aborted
|
|
if (signal?.aborted) {
|
|
reject(new Error("Operation aborted"));
|
|
return;
|
|
}
|
|
|
|
let aborted = false;
|
|
|
|
// Set up abort handler
|
|
const onAbort = () => {
|
|
aborted = true;
|
|
reject(new Error("Operation aborted"));
|
|
};
|
|
|
|
if (signal) {
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
|
|
// Perform the read operation
|
|
(async () => {
|
|
try {
|
|
// Check if file exists
|
|
try {
|
|
await access(absolutePath, constants.R_OK);
|
|
} catch {
|
|
if (signal) {
|
|
signal.removeEventListener("abort", onAbort);
|
|
}
|
|
resolve({
|
|
content: [{ type: "text", text: `Error: File not found: ${path}` }],
|
|
details: undefined,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if aborted before reading
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
|
|
// Read the file based on type
|
|
let content: (TextContent | ImageContent)[];
|
|
|
|
if (mimeType) {
|
|
// Read as image (binary)
|
|
const buffer = await readFile(absolutePath);
|
|
const base64 = buffer.toString("base64");
|
|
|
|
content = [
|
|
{ type: "text", text: `Read image file: ${path}` },
|
|
{ type: "image", data: base64, mimeType },
|
|
];
|
|
} else {
|
|
// Read as text
|
|
const textContent = await readFile(absolutePath, "utf-8");
|
|
const lines = textContent.split("\n");
|
|
|
|
// Apply offset and limit (matching Claude Code Read tool behavior)
|
|
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
|
|
const maxLines = limit || MAX_LINES;
|
|
const endLine = Math.min(startLine + maxLines, lines.length);
|
|
|
|
// Check if offset is out of bounds
|
|
if (startLine >= lines.length) {
|
|
content = [
|
|
{
|
|
type: "text",
|
|
text: `Error: Offset ${offset} is beyond end of file (${lines.length} lines total)`,
|
|
},
|
|
];
|
|
} else {
|
|
// Get the relevant lines
|
|
const selectedLines = lines.slice(startLine, endLine);
|
|
|
|
// Truncate long lines and track which were truncated
|
|
let hadTruncatedLines = false;
|
|
const formattedLines = selectedLines.map((line) => {
|
|
if (line.length > MAX_LINE_LENGTH) {
|
|
hadTruncatedLines = true;
|
|
return line.slice(0, MAX_LINE_LENGTH);
|
|
}
|
|
return line;
|
|
});
|
|
|
|
let outputText = formattedLines.join("\n");
|
|
|
|
// Add notices
|
|
const notices: string[] = [];
|
|
|
|
if (hadTruncatedLines) {
|
|
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
|
}
|
|
|
|
if (endLine < lines.length) {
|
|
const remaining = lines.length - endLine;
|
|
notices.push(
|
|
`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`,
|
|
);
|
|
}
|
|
|
|
if (notices.length > 0) {
|
|
outputText += `\n\n... (${notices.join(". ")})`;
|
|
}
|
|
|
|
content = [{ type: "text", text: outputText }];
|
|
}
|
|
}
|
|
|
|
// Check if aborted after reading
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
|
|
// Clean up abort handler
|
|
if (signal) {
|
|
signal.removeEventListener("abort", onAbort);
|
|
}
|
|
|
|
resolve({ content, details: undefined });
|
|
} catch (error: any) {
|
|
// Clean up abort handler
|
|
if (signal) {
|
|
signal.removeEventListener("abort", onAbort);
|
|
}
|
|
|
|
if (!aborted) {
|
|
reject(error);
|
|
}
|
|
}
|
|
})();
|
|
});
|
|
},
|
|
};
|