Files
tg-chat-bot/src/ai/tools/files.ts
T
2026-05-18 13:31:37 +03:00

2431 lines
73 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {z} from "zod";
import {Environment} from "../../common/environment";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
import {
MAX_COPY_ENTRIES,
MAX_COPY_TOTAL_BYTES,
MAX_DIRECTORY_ENTRIES,
MAX_FILE_ATTACHMENT_BYTES,
MAX_FILE_READ_BYTES,
MAX_FILE_SEARCH_CONTENT_BYTES,
MAX_FILE_SEARCH_ENTRIES,
MAX_FILE_SEARCH_RESULTS,
MAX_FILE_SEARCH_SNIPPET_CHARS,
MAX_FILE_WRITE_BYTES,
MAX_FILE_WRITE_CHUNK_BYTES,
MAX_PATCH_OPERATIONS,
MAX_PATCH_PREVIEW_CHARS,
MAX_PATCH_REPLACE_BYTES,
MAX_PATCH_SEARCH_BYTES,
MAX_STREAM_WRITE_IDLE_MS,
MAX_STREAM_WRITE_SESSIONS,
} from "./limits";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
// =============================================================================
// Public types and schemas
// =============================================================================
export type LocalFileAttachment = {
type: "local_file";
fileName: string;
relativePath: string;
mimeType: string;
sizeBytes: number;
};
export type SendFileAttachmentResult =
| {
success: true;
attachment: LocalFileAttachment;
}
| {
success: false;
error: string;
};
export const LocalFileAttachmentSchema = z.object({
type: z.literal("local_file"),
fileName: z.string(),
relativePath: z.string(),
mimeType: z.string(),
sizeBytes: z.number(),
});
export const SendFileAttachmentResultSchema = z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
attachment: LocalFileAttachmentSchema,
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]);
type CopyPathStats = {
entries: number;
totalBytes: number;
};
type SearchResultType = "file" | "directory";
type FileSearchResult = {
path: string;
name: string;
type: SearchResultType;
sizeBytes: number | null;
modifiedAt: string;
matchedBy: {
name: boolean;
path: boolean;
content: boolean;
};
contentMatch?: {
line: number;
column: number;
snippet: string;
};
};
const PATCH_OPERATION_TYPES = [
"replace",
"insert_before",
"insert_after",
"delete",
] as const;
type PatchOperationType = (typeof PATCH_OPERATION_TYPES)[number];
type ParsedPatchOperation = {
type: PatchOperationType;
search: string;
replace: string;
};
type AppliedPatchOperation = {
index: number;
type: PatchOperationType;
line: number;
column: number;
searchBytes: number;
replaceBytes: number;
};
type FileWriteSession = {
sessionId: string;
targetAbsolutePath: string;
targetRelativePath: string;
tempAbsolutePath: string;
tempRelativePath: string;
overwrite: boolean;
bytesWritten: number;
nextChunkIndex: number;
createdAtMs: number;
updatedAtMs: number;
rootDir: string;
userId?: number | null;
};
const fileWriteSessions = new Map<string, FileWriteSession>();
// =============================================================================
// Tool declarations
// =============================================================================
export const readFileTool = {
type: "function",
function: {
name: "read_file",
description:
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Relative file path inside the root directory, for example notes/task.txt.",
},
maxBytes: {
type: "number",
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const listDirectoryTool = {
type: "function",
function: {
name: "list_directory",
description:
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Relative directory path inside the root directory. Use . for root.",
},
},
required: [],
},
},
} satisfies AiTool;
export const searchFilesTool = {
type: "function",
function: {
name: "search_files",
description:
"Search for files and optionally directories inside the hardcoded root directory. Can search by file name/path and optionally by exact text content. Use only relative paths. Going up with ../ and absolute paths are forbidden. Symlinks are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Relative directory path to search inside. Use . for root. Default is root.",
},
query: {
type: "string",
description:
"Case-insensitive substring to search in file/directory name and relative path. Optional if contentQuery is provided.",
},
contentQuery: {
type: "string",
description:
"Optional exact text substring to search inside UTF-8 text files. Binary files and large files are skipped.",
},
recursive: {
type: "boolean",
description: "Whether to search recursively. Default is true.",
},
caseSensitive: {
type: "boolean",
description:
"Whether query and contentQuery should be case-sensitive. Default is false.",
},
includeDirectories: {
type: "boolean",
description:
"Whether to include matching directories in results. Default is false.",
},
extensions: {
type: "array",
description:
'Optional list of file extensions to include, for example [".ts", ".json"]. Applies only to files.',
items: {
type: "string",
},
},
maxResults: {
type: "number",
description: `Optional max results. Maximum allowed value is ${MAX_FILE_SEARCH_RESULTS}.`,
},
},
required: [],
},
},
} satisfies AiTool;
export const createFileTool = {
type: "function",
function: {
name: "create_file",
description:
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "File content.",
},
overwrite: {
type: "boolean",
description:
"Whether to overwrite the file if it already exists. Default is false.",
},
createParents: {
type: "boolean",
description:
"Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const updateFileTool = {
type: "function",
function: {
name: "update_file",
description:
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "Content to write.",
},
mode: {
type: "string",
enum: ["replace", "append", "prepend"],
description: "Update mode. Default is replace.",
},
createIfMissing: {
type: "boolean",
description:
"Whether to create the file if it does not exist. Default is false.",
},
},
required: ["path", "content"],
},
},
} satisfies AiTool;
export const editFilePatchTool = {
type: "function",
function: {
name: "edit_file_patch",
description:
"Edit a UTF-8 text file inside the hardcoded root directory by applying exact-match patch operations. Use this instead of rewriting the whole file. Every search fragment must match exactly and must appear exactly once.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
operations: {
type: "array",
minItems: 1,
maxItems: MAX_PATCH_OPERATIONS,
description:
"Patch operations applied sequentially. Each search fragment must match the current file content exactly and appear exactly once.",
items: {
type: "object",
properties: {
type: {
type: "string",
enum: ["replace", "insert_before", "insert_after", "delete"],
description: "Patch operation type.",
},
search: {
type: "string",
description:
"Exact text fragment to find in the current file content. Must be copied exactly from read_file output.",
},
replace: {
type: "string",
description:
"Replacement or inserted text. Required for replace, insert_before and insert_after. Ignored for delete.",
},
},
required: ["type", "search"],
},
},
dryRun: {
type: "boolean",
description:
"If true, validate and preview the patch without writing changes. Default is false.",
},
createBackup: {
type: "boolean",
description:
"If true, create a timestamped .bak file before writing changes. Ignored in dryRun mode. Default is false.",
},
},
required: ["path", "operations"],
},
},
} satisfies AiTool;
export const createDirectoryTool = {
type: "function",
function: {
name: "create_directory",
description:
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory.",
},
recursive: {
type: "boolean",
description:
"Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const copyPathTool = {
type: "function",
function: {
name: "copy_path",
description:
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description:
"Relative source file or directory path inside the root directory.",
},
targetPath: {
type: "string",
description:
"Relative target file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Required for copying directories. Default is false.",
},
overwrite: {
type: "boolean",
description:
"Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
},
createParents: {
type: "boolean",
description:
"Whether to create target parent directories automatically. Default is true.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const renamePathTool = {
type: "function",
function: {
name: "rename_path",
description:
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target path inside the root directory.",
},
overwrite: {
type: "boolean",
description:
"Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
},
createParents: {
type: "boolean",
description:
"Whether to create target parent directories automatically. Default is false.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const deletePathTool = {
type: "function",
function: {
name: "delete_path",
description:
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Relative file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description:
"Whether to delete non-empty directories recursively. Default is false.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const sendFileAsAttachmentTool = {
type: "function",
function: {
name: "send_file_as_attachment",
description:
"Prepare a file inside the hardcoded root directory to be sent to the user as an attachment. Returns a local file descriptor that the host application should use to upload or send the file. Does not return file bytes or file content.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Relative file path inside the root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
},
fileName: {
type: "string",
description:
'Optional attachment file name visible to the user. If omitted, the original file basename is used. Must not contain /, \\, :, *, ?, \", <, >, |, or control characters.',
},
maxBytes: {
type: "number",
description: `Optional max allowed file size. Maximum allowed value is ${MAX_FILE_ATTACHMENT_BYTES}.`,
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const beginFileWriteTool = {
type: "function",
function: {
name: "begin_file_write",
description:
"Begin chunked creation of a UTF-8 text file inside the hardcoded root directory. Creates a temporary file and returns a sessionId. Use this for large files instead of create_file.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative target file path inside the root directory.",
},
overwrite: {
type: "boolean",
description:
"Whether to overwrite the target file if it already exists. Default is false.",
},
createParents: {
type: "boolean",
description:
"Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const writeFileChunkTool = {
type: "function",
function: {
name: "write_file_chunk",
description:
"Append one UTF-8 text chunk to an active chunked file write session. Chunks must be written sequentially by chunkIndex starting from 1.",
parameters: {
type: "object",
properties: {
sessionId: {
type: "string",
description: "Session id returned by begin_file_write.",
},
chunkIndex: {
type: "number",
description: "Sequential chunk number starting from 1.",
},
chunk: {
type: "string",
description: `UTF-8 text chunk. Maximum allowed size is ${MAX_FILE_WRITE_CHUNK_BYTES} bytes.`,
},
},
required: ["sessionId", "chunkIndex", "chunk"],
},
},
} satisfies AiTool;
export const finishFileWriteTool = {
type: "function",
function: {
name: "finish_file_write",
description:
"Finish an active chunked file write session by moving the temporary file to the final target path.",
parameters: {
type: "object",
properties: {
sessionId: {
type: "string",
description: "Session id returned by begin_file_write.",
},
},
required: ["sessionId"],
},
},
} satisfies AiTool;
export const cancelFileWriteTool = {
type: "function",
function: {
name: "cancel_file_write",
description:
"Cancel an active chunked file write session and delete the temporary file.",
parameters: {
type: "object",
properties: {
sessionId: {
type: "string",
description: "Session id returned by begin_file_write.",
},
},
required: ["sessionId"],
},
},
} satisfies AiTool;
export const fileToolsToolPrompt = [
"Filesystem tool rules:",
"- You have access to filesystem tools working only inside the hardcoded root directory.",
"- All filesystem paths must be relative to the root directory.",
"- You may go into child directories.",
"- You must never go up to parent directories.",
"- Do not use ../ paths.",
"- Do not use absolute paths.",
"- Do not try to access symlinks.",
"- Use search_files to find files by name, path or text content before reading or editing unfamiliar files.",
"- Use read_file for reading files.",
"- Use list_directory for reading directories.",
"- Use create_file for creating small or medium files in one call.",
"- Use begin_file_write, write_file_chunk and finish_file_write for large files.",
"- For chunked file writing, chunkIndex starts from 1 and must increase by 1 on every write_file_chunk call.",
"- If chunked file writing fails or is no longer needed, use cancel_file_write.",
"- Use create_directory for creating directories.",
"- Use update_file for replacing, appending or prepending file content.",
"- Use edit_file_patch for small exact-match file edits instead of rewriting the whole file.",
"- Before using edit_file_patch, read the relevant file or fragment first.",
"- For edit_file_patch, search fragments must be copied exactly from current file content.",
"- Do not guess patch context. If unsure, read the file first.",
"- Use rename_path for renaming or moving files/directories inside the root.",
"- Use delete_path for deleting files/directories inside the root.",
"- Use send_file_as_attachment when the user asks to receive, download, export or upload a file as an attachment.",
"- send_file_as_attachment returns only a local file descriptor. The host application must actually send the file.",
"",
].join("\n");
// =============================================================================
// Exported tool implementations
// =============================================================================
export async function readFile(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
await assertNoSymlinkInPath(absolutePath, rootDir);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
const maxBytes = asPositiveInt(
args?.maxBytes,
MAX_FILE_READ_BYTES,
MAX_FILE_READ_BYTES,
);
if (stat.size > maxBytes) {
throw new Error(
`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`,
);
}
const buffer = await fs.promises.readFile(absolutePath);
if (buffer.includes(0)) {
throw new Error("Binary files are not supported.");
}
return {
ok: true,
path: relativePath,
sizeBytes: stat.size,
content: buffer.toString("utf8"),
};
}
export async function listDirectory(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
await assertNoSymlinkInPath(absolutePath, rootDir);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${relativePath}`);
}
const dirEntries = await fs.promises.readdir(absolutePath, {
withFileTypes: true,
});
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
const entries = await Promise.all(
limitedEntries.map(async (entry) => {
const entryAbsolutePath = path.join(absolutePath, entry.name);
const entryRelativePath =
relativePath === "." ? entry.name : path.join(relativePath, entry.name);
const entryStat = await fs.promises.lstat(entryAbsolutePath);
return {
name: entry.name,
path: entryRelativePath,
type: getEntryType(entryStat),
sizeBytes: entryStat.isFile() ? entryStat.size : null,
modifiedAt: entryStat.mtime.toISOString(),
};
}),
);
return {
ok: true,
path: relativePath,
entries,
totalEntries: dirEntries.length,
returnedEntries: entries.length,
truncated: dirEntries.length > entries.length,
};
}
export async function searchFiles(args?: AiJsonObject) {
const start = resolveSafeToolPath(args?.path, ".", args?.userId);
await assertNoSymlinkInPath(start.absolutePath, start.rootDir);
const startStat = await fs.promises.lstat(start.absolutePath);
if (!startStat.isDirectory()) {
throw new Error(`Search path is not a directory: ${start.relativePath}`);
}
const query = asNonEmptyString(args?.query);
const contentQuery = asNonEmptyString(args?.contentQuery);
if (!query && !contentQuery) {
throw new Error("Either query or contentQuery must be provided.");
}
const recursive = asBoolean(args?.recursive, true);
const caseSensitive = asBoolean(args?.caseSensitive, false);
const includeDirectories = asBoolean(args?.includeDirectories, false);
const extensions = parseSearchExtensions(args?.extensions);
const maxResults = asPositiveInt(
args?.maxResults,
MAX_FILE_SEARCH_RESULTS,
MAX_FILE_SEARCH_RESULTS,
);
const normalizedQuery = query
? normalizeForSearch(query, caseSensitive)
: null;
const results: FileSearchResult[] = [];
const pendingDirectories: Array<{
absolutePath: string;
relativePath: string;
}> = [start];
let scannedEntries = 0;
let truncated = false;
while (pendingDirectories.length > 0) {
const current = pendingDirectories.shift();
if (!current) {
break;
}
const entries = await fs.promises.readdir(current.absolutePath, {
withFileTypes: true,
});
for (const entry of entries) {
scannedEntries++;
if (scannedEntries > MAX_FILE_SEARCH_ENTRIES) {
truncated = true;
pendingDirectories.length = 0;
break;
}
if (results.length >= maxResults) {
truncated = true;
pendingDirectories.length = 0;
break;
}
const entryAbsolutePath = path.join(current.absolutePath, entry.name);
const entryRelativePath =
current.relativePath === "."
? entry.name
: path.join(current.relativePath, entry.name);
const entryStat = await fs.promises.lstat(entryAbsolutePath);
if (entryStat.isSymbolicLink()) {
continue;
}
const isDirectory = entryStat.isDirectory();
const isFile = entryStat.isFile();
if (!isDirectory && !isFile) {
continue;
}
if (isDirectory && recursive) {
pendingDirectories.push({
absolutePath: entryAbsolutePath,
relativePath: entryRelativePath,
});
}
if (isFile && !matchesExtension(entryRelativePath, extensions)) {
continue;
}
if (isDirectory && !includeDirectories) {
continue;
}
const normalizedName = normalizeForSearch(entry.name, caseSensitive);
const normalizedPath = normalizeForSearch(
entryRelativePath,
caseSensitive,
);
const matchedByName = normalizedQuery
? normalizedName.includes(normalizedQuery)
: false;
const matchedByPath = normalizedQuery
? normalizedPath.includes(normalizedQuery)
: false;
let contentMatch: FileSearchResult["contentMatch"] | undefined;
if (isFile && contentQuery) {
const match = await tryFindTextInFile({
absolutePath: entryAbsolutePath,
query: contentQuery,
caseSensitive,
});
if (match) {
contentMatch = match;
}
}
const matchedByContent = Boolean(contentMatch);
if (!matchedByName && !matchedByPath && !matchedByContent) {
continue;
}
results.push({
path: entryRelativePath,
name: entry.name,
type: isDirectory ? "directory" : "file",
sizeBytes: isFile ? entryStat.size : null,
modifiedAt: entryStat.mtime.toISOString(),
matchedBy: {
name: matchedByName,
path: matchedByPath,
content: matchedByContent,
},
contentMatch,
});
}
}
return {
ok: true,
path: start.relativePath,
query: query ?? null,
contentQuery: contentQuery ?? null,
recursive,
caseSensitive,
includeDirectories,
extensions,
scannedEntries,
returnedResults: results.length,
maxResults,
truncated,
results,
};
}
export async function createFile(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(
`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`,
);
}
const parentPath = path.dirname(absolutePath);
if (createParents) {
await assertNoSymlinkInPath(parentPath, rootDir, {allowMissingTail: true});
await fs.promises.mkdir(parentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(parentPath, rootDir);
}
if (await pathExists(absolutePath)) {
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (stat.isDirectory()) {
throw new Error(`Path is a directory, not a file: ${relativePath}`);
}
if (!overwrite) {
throw new Error(`File already exists: ${relativePath}`);
}
}
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: overwrite ? "w" : "wx",
});
return {
ok: true,
path: relativePath,
sizeBytes: contentSizeBytes,
overwritten: overwrite,
};
}
export async function updateFile(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
const createIfMissing = asBoolean(args?.createIfMissing, false);
if (!["replace", "append", "prepend"].includes(mode)) {
throw new Error(`Unsupported update mode: ${mode}`);
}
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(
`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`,
);
}
const parentPath = path.dirname(absolutePath);
await assertNoSymlinkInPath(parentPath, rootDir);
const exists = await pathExists(absolutePath);
if (!exists && !createIfMissing) {
throw new Error(`File does not exist: ${relativePath}`);
}
if (exists) {
await assertNoSymlinkInPath(absolutePath, rootDir);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
}
if (mode === "replace") {
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: "w",
});
} else if (mode === "append") {
await fs.promises.appendFile(absolutePath, content, {
encoding: "utf8",
});
} else {
const oldContent = exists
? await fs.promises.readFile(absolutePath, "utf8")
: "";
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(
`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`,
);
}
await fs.promises.writeFile(absolutePath, content + oldContent, {
encoding: "utf8",
flag: "w",
});
}
const newStat = await fs.promises.stat(absolutePath);
return {
ok: true,
path: relativePath,
mode,
sizeBytes: newStat.size,
created: !exists,
};
}
export async function editFilePatch(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
assertNotRoot(relativePath);
await assertNoSymlinkInPath(absolutePath, rootDir);
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
if (stat.size > MAX_FILE_READ_BYTES) {
throw new Error(
`File is too large to patch: ${stat.size} bytes. Max allowed: ${MAX_FILE_READ_BYTES} bytes.`,
);
}
const operations = parsePatchOperations(args?.operations);
const dryRun = asBoolean(args?.dryRun, false);
const createBackup = asBoolean(args?.createBackup, false);
const buffer = await fs.promises.readFile(absolutePath);
if (buffer.includes(0)) {
throw new Error("Binary files are not supported.");
}
const originalContent = buffer.toString("utf8");
let content = originalContent;
const appliedOperations: AppliedPatchOperation[] = [];
for (const [index, operation] of operations.entries()) {
const occurrences = findExactOccurrences(content, operation.search);
if (occurrences.length === 0) {
throw new Error(
`Operation #${index} failed: search fragment was not found.`,
);
}
if (occurrences.length > 1) {
throw new Error(
`Operation #${index} failed: search fragment is ambiguous and appears ${occurrences.length} times.`,
);
}
const position = occurrences[0];
const location = getLineColumn(content, position);
const replacement = buildPatchReplacement(operation);
content = replaceAt(
content,
position,
operation.search.length,
replacement,
);
const resultSizeBytes = Buffer.byteLength(content, "utf8");
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(
`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`,
);
}
appliedOperations.push({
index,
type: operation.type,
line: location.line,
column: location.column,
searchBytes: Buffer.byteLength(operation.search, "utf8"),
replaceBytes: Buffer.byteLength(replacement, "utf8"),
});
}
const changed = content !== originalContent;
let backupPath: string | null = null;
if (!dryRun && changed) {
if (createBackup) {
backupPath = await createPatchBackup(absolutePath, originalContent, rootDir);
}
await writeTextFileAtomic(absolutePath, content, rootDir);
}
return {
ok: true,
path: relativePath,
dryRun,
changed,
backupPath,
operationsApplied: appliedOperations,
beforeSizeBytes: Buffer.byteLength(originalContent, "utf8"),
afterSizeBytes: Buffer.byteLength(content, "utf8"),
preview: dryRun ? buildPatchPreview(originalContent, content) : undefined,
};
}
export async function createDirectory(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
const recursive = asBoolean(args?.recursive, true);
await assertNoSymlinkInPath(absolutePath, rootDir, {allowMissingTail: true});
await fs.promises.mkdir(absolutePath, {
recursive,
});
return {
ok: true,
path: relativePath,
recursive,
};
}
export async function copyPath(args?: AiJsonObject) {
const source = resolveSafeToolPath(args?.sourcePath, undefined, args?.userId);
const target = resolveSafeToolPath(args?.targetPath, undefined, args?.userId);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath, source.rootDir);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink sources are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
if (sourceStat.isDirectory() && !recursive) {
throw new Error(
"Source is a directory. Set recursive=true to copy directories.",
);
}
if (sourceStat.isDirectory()) {
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
}
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, source.rootDir, {
allowMissingTail: true,
});
await fs.promises.mkdir(targetParentPath, {
recursive: true,
});
await assertNoSymlinkInPath(targetParentPath, source.rootDir);
} else {
await assertNoSymlinkInPath(targetParentPath, source.rootDir);
}
const stats: CopyPathStats = {
entries: 0,
totalBytes: 0,
};
await copyPathRecursive({
sourceAbsolutePath: source.absolutePath,
targetAbsolutePath: target.absolutePath,
overwrite,
stats,
rootDir: source.rootDir
});
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
recursive,
overwrite,
entriesCopied: stats.entries,
bytesCopied: stats.totalBytes,
};
}
export async function renamePath(args?: AiJsonObject) {
const source = resolveSafeToolPath(args?.sourcePath, undefined, args?.userId);
const target = resolveSafeToolPath(args?.targetPath, undefined, args?.userId);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath, source.rootDir);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const relativeTargetInsideSource = path.relative(
source.absolutePath,
target.absolutePath,
);
if (
relativeTargetInsideSource === "" ||
(!relativeTargetInsideSource.startsWith("..") &&
!path.isAbsolute(relativeTargetInsideSource))
) {
throw new Error("Cannot move a directory into itself.");
}
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, false);
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, target.rootDir, {allowMissingTail: true});
await fs.promises.mkdir(targetParentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(targetParentPath, target.rootDir);
}
if (await pathExists(target.absolutePath)) {
const targetStat = await fs.promises.lstat(target.absolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!overwrite) {
throw new Error(`Target already exists: ${target.relativePath}`);
}
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
throw new Error("Overwrite for directories is not supported.");
}
await fs.promises.rm(target.absolutePath, {
force: false,
});
}
await fs.promises.rename(source.absolutePath, target.absolutePath);
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
overwrite,
};
}
export async function deletePath(args?: AiJsonObject) {
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
args?.path,
".",
args?.userId,
);
assertNotRoot(relativePath);
await assertNoSymlinkInPath(absolutePath, rootDir);
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
if (stat.isDirectory()) {
if (recursive) {
await fs.promises.rm(absolutePath, {
recursive: true,
force: false,
});
} else {
await fs.promises.rmdir(absolutePath);
}
} else {
await fs.promises.rm(absolutePath, {
force: false,
});
}
return {
ok: true,
path: relativePath,
recursive,
deleted: true,
};
}
export async function sendFileAsAttachment(
args?: AiJsonObject,
): Promise<SendFileAttachmentResult> {
try {
const target = resolveSafeToolPath(args?.path, undefined, args?.userId);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(target.absolutePath, target.rootDir);
const stat = await fs.promises.lstat(target.absolutePath);
if (stat.isSymbolicLink()) {
return {
success: false,
error: "Symlink targets are not allowed.",
};
}
if (!stat.isFile()) {
return {
success: false,
error: `Path is not a file: ${target.relativePath}`,
};
}
const maxBytes = asPositiveInt(
args?.maxBytes,
MAX_FILE_ATTACHMENT_BYTES,
MAX_FILE_ATTACHMENT_BYTES,
);
if (stat.size > maxBytes) {
return {
success: false,
error: `File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`,
};
}
const requestedFileName = asNonEmptyString(args?.fileName);
const fileName =
requestedFileName?.trim() || path.basename(target.relativePath);
if (!isSafeAttachmentFileName(fileName)) {
return {
success: false,
error: "Invalid or unsafe attachment file name provided.",
};
}
return {
success: true,
attachment: {
type: "local_file",
fileName,
relativePath: target.relativePath,
mimeType: guessMimeType(fileName),
sizeBytes: stat.size,
},
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to prepare file attachment: ${errorMessage}`,
};
}
}
export async function beginFileWrite(args?: AiJsonObject) {
await cleanupExpiredFileWriteSessions();
if (fileWriteSessions.size >= MAX_STREAM_WRITE_SESSIONS) {
throw new Error(
`Too many active file write sessions. Max allowed: ${MAX_STREAM_WRITE_SESSIONS}.`,
);
}
const target = resolveSafeToolPath(args?.path, undefined, args?.userId);
assertNotRoot(target.relativePath);
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, target.rootDir, {
allowMissingTail: true,
});
await fs.promises.mkdir(targetParentPath, {
recursive: true,
});
await assertNoSymlinkInPath(targetParentPath, target.rootDir);
} else {
await assertNoSymlinkInPath(targetParentPath, target.rootDir);
}
if (await pathExists(target.absolutePath)) {
const targetStat = await fs.promises.lstat(target.absolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (targetStat.isDirectory()) {
throw new Error(
`Path is a directory, not a file: ${target.relativePath}`,
);
}
if (!overwrite) {
throw new Error(`File already exists: ${target.relativePath}`);
}
}
const sessionId = crypto.randomUUID();
const tempAbsolutePath = path.join(
targetParentPath,
`.${path.basename(target.absolutePath)}.${sessionId}.tmp`,
);
const tempRelativePath = path.relative(target.rootDir, tempAbsolutePath);
await fs.promises.writeFile(tempAbsolutePath, "", {
encoding: "utf8",
flag: "wx",
});
const now = Date.now();
const session: FileWriteSession = {
sessionId,
targetAbsolutePath: target.absolutePath,
targetRelativePath: target.relativePath,
tempAbsolutePath,
tempRelativePath,
overwrite,
bytesWritten: 0,
nextChunkIndex: 1,
createdAtMs: now,
updatedAtMs: now,
rootDir: target.rootDir,
userId: parseTelegramUserId(args?.userId)
};
fileWriteSessions.set(sessionId, session);
return {
ok: true,
sessionId,
path: target.relativePath,
tempPath: tempRelativePath,
overwrite,
nextChunkIndex: session.nextChunkIndex,
bytesWritten: session.bytesWritten,
};
}
export async function writeFileChunk(args?: AiJsonObject) {
await cleanupExpiredFileWriteSessions();
const session = getFileWriteSession(args?.sessionId);
const chunkIndex = parsePositiveInteger(args?.chunkIndex, "chunkIndex");
if (chunkIndex !== session.nextChunkIndex) {
throw new Error(
`Invalid chunkIndex. Expected ${session.nextChunkIndex}, got ${chunkIndex}.`,
);
}
const chunk = asString(args?.chunk, "");
if (chunk.includes("\0")) {
throw new Error("Binary content is not supported.");
}
const chunkSizeBytes = Buffer.byteLength(chunk, "utf8");
if (chunkSizeBytes > MAX_FILE_WRITE_CHUNK_BYTES) {
throw new Error(
`Chunk is too large: ${chunkSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_CHUNK_BYTES} bytes.`,
);
}
const resultSizeBytes = session.bytesWritten + chunkSizeBytes;
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(
`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`,
);
}
await assertNoSymlinkInPath(session.tempAbsolutePath, session.rootDir);
const tempStat = await fs.promises.lstat(session.tempAbsolutePath);
if (!tempStat.isFile()) {
throw new Error("Temporary write path is not a file.");
}
if (tempStat.isSymbolicLink()) {
throw new Error("Symlink temporary files are not allowed.");
}
await fs.promises.appendFile(session.tempAbsolutePath, chunk, {
encoding: "utf8",
});
session.bytesWritten = resultSizeBytes;
session.nextChunkIndex++;
session.updatedAtMs = Date.now();
return {
ok: true,
sessionId: session.sessionId,
path: session.targetRelativePath,
acceptedChunkIndex: chunkIndex,
chunkSizeBytes,
bytesWritten: session.bytesWritten,
nextChunkIndex: session.nextChunkIndex,
};
}
export async function finishFileWrite(args?: AiJsonObject) {
await cleanupExpiredFileWriteSessions();
const session = getFileWriteSession(args?.sessionId);
await assertNoSymlinkInPath(path.dirname(session.targetAbsolutePath), session.rootDir);
await assertNoSymlinkInPath(session.tempAbsolutePath, session.rootDir);
const tempStat = await fs.promises.lstat(session.tempAbsolutePath);
if (!tempStat.isFile()) {
throw new Error("Temporary write path is not a file.");
}
if (tempStat.isSymbolicLink()) {
throw new Error("Symlink temporary files are not allowed.");
}
if (await pathExists(session.targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(session.targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (targetStat.isDirectory()) {
throw new Error(
`Path is a directory, not a file: ${session.targetRelativePath}`,
);
}
if (!session.overwrite) {
throw new Error(`File already exists: ${session.targetRelativePath}`);
}
await fs.promises.rm(session.targetAbsolutePath, {
force: false,
});
}
await fs.promises.rename(
session.tempAbsolutePath,
session.targetAbsolutePath,
);
fileWriteSessions.delete(session.sessionId);
const finalStat = await fs.promises.stat(session.targetAbsolutePath);
return {
ok: true,
sessionId: session.sessionId,
path: session.targetRelativePath,
sizeBytes: finalStat.size,
chunksWritten: session.nextChunkIndex - 1,
overwritten: session.overwrite,
};
}
export async function cancelFileWrite(args?: AiJsonObject) {
const session = getFileWriteSession(args?.sessionId);
fileWriteSessions.delete(session.sessionId);
await fs.promises.rm(session.tempAbsolutePath, {
force: true,
});
return {
ok: true,
sessionId: session.sessionId,
path: session.targetRelativePath,
cancelled: true,
bytesWritten: session.bytesWritten,
chunksWritten: session.nextChunkIndex - 1,
};
}
// =============================================================================
// Path and filesystem helpers
// =============================================================================
function parseTelegramUserId(input: AiJsonValue | null | undefined): number | null {
if (input === null || input === undefined) {
return null;
}
if (
typeof input !== "number" ||
!Number.isSafeInteger(input) ||
input <= 0
) {
throw new Error("userId must be a positive safe integer.");
}
return input;
}
function requireFileToolsRootDir(userIdInput?: AiJsonValue | null | undefined): string {
const baseRootDir = Environment.FILE_TOOLS_ROOT_DIR as string;
const userId = parseTelegramUserId(userIdInput);
if (userId === null) {
return baseRootDir;
}
return path.join(baseRootDir, String(userId));
}
async function ensureFileToolsRootExists(rootDir: string): Promise<void> {
await fs.promises.mkdir(rootDir, {recursive: true});
const stat = await fs.promises.stat(rootDir);
if (!stat.isDirectory()) {
throw new Error(`File tools root is not a directory: ${rootDir}`);
}
}
function resolveSafeToolPath(
inputPath: AiJsonValue | null | undefined,
fallback = ".",
userIdInput?: AiJsonValue | null | undefined,
): {
absolutePath: string;
relativePath: string;
rootDir: string;
} {
const rootDir = requireFileToolsRootDir(userIdInput);
const rawPath = asNonEmptyString(inputPath) ?? fallback;
if (rawPath.includes("\0")) {
throw new Error("Path must not contain null bytes.");
}
if (
path.isAbsolute(rawPath) ||
path.win32.isAbsolute(rawPath) ||
path.posix.isAbsolute(rawPath)
) {
throw new Error(
"Absolute paths are not allowed. Use only relative paths inside the root directory.",
);
}
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
const absolutePath = path.resolve(rootDir, normalizedInputPath);
const relativePath = path.relative(rootDir, absolutePath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(
"Path escapes the root directory. Going up is not allowed.",
);
}
return {
absolutePath,
relativePath: relativePath || ".",
rootDir,
};
}
function assertNotRoot(relativePath: string): void {
if (relativePath === ".") {
throw new Error("Operation on the root directory itself is not allowed.");
}
}
function assertTargetIsNotInsideSource(
sourceAbsolutePath: string,
targetAbsolutePath: string,
): void {
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
throw new Error("Cannot copy a directory into itself.");
}
}
async function assertNoSymlinkInPath(
absolutePath: string,
rootDir: string,
options?: {
allowMissingTail?: boolean;
},
): Promise<void> {
await ensureFileToolsRootExists(rootDir);
const relativePath = path.relative(rootDir, absolutePath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error("Path escapes the root directory.");
}
if (!relativePath || relativePath === ".") {
return;
}
const parts = relativePath.split(path.sep).filter(Boolean);
let currentPath = rootDir;
for (const part of parts) {
currentPath = path.join(currentPath, part);
try {
const stat = await fs.promises.lstat(currentPath);
if (stat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in file tool paths.");
}
} catch (error) {
if (error instanceof Error && "code" in error && (error as {code?: string}).code === "ENOENT" && options?.allowMissingTail) {
return;
}
throw error;
}
}
}
async function pathExists(absolutePath: string): Promise<boolean> {
try {
await fs.promises.lstat(absolutePath);
return true;
} catch (error) {
if (error instanceof Error && "code" in error && (error as {code?: string}).code === "ENOENT") {
return false;
}
throw error;
}
}
async function writeTextFileAtomic(
absolutePath: string,
content: string,
rootDir: string
): Promise<void> {
const directory = path.dirname(absolutePath);
const basename = path.basename(absolutePath);
const tempPath = path.join(
directory,
`.${basename}.${process.pid}.${Date.now()}.tmp`,
);
try {
await fs.promises.writeFile(tempPath, content, {
encoding: "utf8",
flag: "wx",
});
await assertNoSymlinkInPath(absolutePath, rootDir);
const targetStat = await fs.promises.lstat(absolutePath);
if (!targetStat.isFile()) {
throw new Error(
"Target path stopped being a regular file during patch write.",
);
}
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
await fs.promises.rename(tempPath, absolutePath);
} catch (error) {
await fs.promises.rm(tempPath, {
force: true,
});
throw error;
}
}
async function createPatchBackup(
absolutePath: string,
originalContent: string,
rootDir: string,
): Promise<string> {
const safeTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupAbsolutePath = `${absolutePath}.bak.${safeTimestamp}`;
await fs.promises.writeFile(backupAbsolutePath, originalContent, {
encoding: "utf8",
flag: "wx",
});
return path.relative(rootDir, backupAbsolutePath);
}
function getEntryType(
stat: fs.Stats,
): "file" | "directory" | "symlink" | "other" {
if (stat.isSymbolicLink()) return "symlink";
if (stat.isFile()) return "file";
if (stat.isDirectory()) return "directory";
return "other";
}
// =============================================================================
// Copy helpers
// =============================================================================
async function copyPathRecursive(params: {
sourceAbsolutePath: string;
targetAbsolutePath: string;
overwrite: boolean;
stats: CopyPathStats;
rootDir: string;
}): Promise<void> {
const {sourceAbsolutePath, targetAbsolutePath, overwrite, stats} = params;
if (stats.entries >= MAX_COPY_ENTRIES) {
throw new Error(
`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`,
);
}
stats.entries++;
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in copied paths.");
}
if (sourceStat.isFile()) {
stats.totalBytes += sourceStat.size;
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
throw new Error(
`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`,
);
}
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (targetStat.isDirectory()) {
throw new Error("Cannot overwrite a directory with a file.");
}
if (!overwrite) {
throw new Error(
`Target file already exists: ${path.relative(params.rootDir, targetAbsolutePath)}`,
);
}
}
await fs.promises.copyFile(
sourceAbsolutePath,
targetAbsolutePath,
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
);
return;
}
if (sourceStat.isDirectory()) {
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!targetStat.isDirectory()) {
throw new Error("Cannot overwrite a file with a directory.");
}
} else {
await fs.promises.mkdir(targetAbsolutePath);
}
const entries = await fs.promises.readdir(sourceAbsolutePath);
for (const entry of entries) {
const childSourcePath = path.join(sourceAbsolutePath, entry);
const childTargetPath = path.join(targetAbsolutePath, entry);
await copyPathRecursive({
sourceAbsolutePath: childSourcePath,
targetAbsolutePath: childTargetPath,
overwrite,
stats,
rootDir: params.rootDir,
});
}
return;
}
throw new Error("Only files and directories can be copied.");
}
// =============================================================================
// Patch helpers
// =============================================================================
function isPatchOperationType(value: string): value is PatchOperationType {
return (PATCH_OPERATION_TYPES as readonly string[]).includes(value);
}
function parsePatchOperations(input: AiJsonValue | null | undefined): ParsedPatchOperation[] {
if (!Array.isArray(input)) {
throw new Error("operations must be an array.");
}
if (input.length === 0) {
throw new Error("operations must not be empty.");
}
if (input.length > MAX_PATCH_OPERATIONS) {
throw new Error(
`Too many patch operations. Max allowed: ${MAX_PATCH_OPERATIONS}.`,
);
}
return input.map((rawOperation, index) => {
if (
!rawOperation ||
typeof rawOperation !== "object" ||
Array.isArray(rawOperation)
) {
throw new Error(`Operation #${index} must be an object.`);
}
const operation = rawOperation as AiJsonObject;
const rawType = asNonEmptyString(operation.type)?.toLowerCase();
if (!rawType || !isPatchOperationType(rawType)) {
throw new Error(
`Operation #${index} has unsupported type: ${String(operation.type)}.`,
);
}
const search = asNonEmptyString(operation.search);
if (!search) {
throw new Error(
`Operation #${index}: search must be a non-empty string.`,
);
}
const searchBytes = Buffer.byteLength(search, "utf8");
if (searchBytes > MAX_PATCH_SEARCH_BYTES) {
throw new Error(
`Operation #${index}: search fragment is too large: ${searchBytes} bytes. Max allowed: ${MAX_PATCH_SEARCH_BYTES} bytes.`,
);
}
let replace = "";
if (rawType !== "delete") {
if (typeof operation.replace !== "string") {
throw new Error(`Operation #${index}: replace must be a string.`);
}
replace = operation.replace;
const replaceBytes = Buffer.byteLength(replace, "utf8");
if (replaceBytes > MAX_PATCH_REPLACE_BYTES) {
throw new Error(
`Operation #${index}: replace fragment is too large: ${replaceBytes} bytes. Max allowed: ${MAX_PATCH_REPLACE_BYTES} bytes.`,
);
}
}
return {
type: rawType,
search,
replace,
};
});
}
function findExactOccurrences(content: string, search: string): number[] {
const positions: number[] = [];
let fromIndex = 0;
while (true) {
const index = content.indexOf(search, fromIndex);
if (index === -1) {
break;
}
positions.push(index);
fromIndex = index + search.length;
}
return positions;
}
function getLineColumn(
content: string,
index: number,
): {
line: number;
column: number;
} {
const before = content.slice(0, index);
const lines = before.split("\n");
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
};
}
function buildPatchReplacement(operation: ParsedPatchOperation): string {
if (operation.type === "replace") {
return operation.replace;
}
if (operation.type === "insert_before") {
return operation.replace + operation.search;
}
if (operation.type === "insert_after") {
return operation.search + operation.replace;
}
return "";
}
function replaceAt(
content: string,
startIndex: number,
searchLength: number,
replacement: string,
): string {
return (
content.slice(0, startIndex) +
replacement +
content.slice(startIndex + searchLength)
);
}
function buildPatchPreview(before: string, after: string): string {
if (before === after) {
return "No content changes.";
}
let prefixLength = 0;
while (
prefixLength < before.length &&
prefixLength < after.length &&
before[prefixLength] === after[prefixLength]
) {
prefixLength++;
}
let suffixLength = 0;
while (
suffixLength < before.length - prefixLength &&
suffixLength < after.length - prefixLength &&
before[before.length - 1 - suffixLength] ===
after[after.length - 1 - suffixLength]
) {
suffixLength++;
}
const contextChars = Math.floor(MAX_PATCH_PREVIEW_CHARS / 4);
const beforeChangedStart = Math.max(0, prefixLength - contextChars);
const beforeChangedEnd = Math.min(
before.length,
before.length - suffixLength + contextChars,
);
const afterChangedStart = Math.max(0, prefixLength - contextChars);
const afterChangedEnd = Math.min(
after.length,
after.length - suffixLength + contextChars,
);
const beforeSnippet = before.slice(beforeChangedStart, beforeChangedEnd);
const afterSnippet = after.slice(afterChangedStart, afterChangedEnd);
const preview = [
"--- BEFORE ---",
beforeChangedStart > 0 ? "... truncated ..." : "",
beforeSnippet,
beforeChangedEnd < before.length ? "... truncated ..." : "",
"--- AFTER ---",
afterChangedStart > 0 ? "... truncated ..." : "",
afterSnippet,
afterChangedEnd < after.length ? "... truncated ..." : "",
]
.filter(Boolean)
.join("\n");
if (preview.length <= MAX_PATCH_PREVIEW_CHARS) {
return preview;
}
return `${preview.slice(0, MAX_PATCH_PREVIEW_CHARS)}\n... preview truncated ...`;
}
// =============================================================================
// Search helpers
// =============================================================================
function normalizeForSearch(value: string, caseSensitive: boolean): string {
return caseSensitive ? value : value.toLowerCase();
}
function parseSearchExtensions(input: AiJsonValue | null | undefined): string[] | null {
if (input === undefined || input === null) {
return null;
}
if (!Array.isArray(input)) {
throw new Error("extensions must be an array of strings.");
}
const extensions = input
.map((value) => asNonEmptyString(value))
.filter((value): value is string => Boolean(value))
.map((value) => {
const trimmed = value.trim();
return trimmed.startsWith(".")
? trimmed.toLowerCase()
: `.${trimmed.toLowerCase()}`;
});
return extensions.length > 0 ? [...new Set(extensions)] : null;
}
function matchesExtension(
relativePath: string,
extensions: string[] | null,
): boolean {
if (!extensions) {
return true;
}
return extensions.includes(path.extname(relativePath).toLowerCase());
}
function findContentMatch(params: {
content: string;
query: string;
caseSensitive: boolean;
}): {
line: number;
column: number;
snippet: string;
} | null {
const normalizedContent = normalizeForSearch(
params.content,
params.caseSensitive,
);
const normalizedQuery = normalizeForSearch(
params.query,
params.caseSensitive,
);
const index = normalizedContent.indexOf(normalizedQuery);
if (index === -1) {
return null;
}
const before = params.content.slice(0, index);
const lines = before.split("\n");
const line = lines.length;
const column = lines[lines.length - 1].length + 1;
const snippetStart = Math.max(
0,
index - Math.floor(MAX_FILE_SEARCH_SNIPPET_CHARS / 2),
);
const snippetEnd = Math.min(
params.content.length,
index + params.query.length + Math.floor(MAX_FILE_SEARCH_SNIPPET_CHARS / 2),
);
const snippet = [
snippetStart > 0 ? "... " : "",
params.content.slice(snippetStart, snippetEnd),
snippetEnd < params.content.length ? " ..." : "",
].join("");
return {
line,
column,
snippet,
};
}
async function tryFindTextInFile(params: {
absolutePath: string;
query: string;
caseSensitive: boolean;
}): Promise<{
line: number;
column: number;
snippet: string;
} | null> {
const stat = await fs.promises.lstat(params.absolutePath);
if (!stat.isFile()) {
return null;
}
if (stat.size > MAX_FILE_SEARCH_CONTENT_BYTES) {
return null;
}
const buffer = await fs.promises.readFile(params.absolutePath);
if (buffer.includes(0)) {
return null;
}
const content = buffer.toString("utf8");
return findContentMatch({
content,
query: params.query,
caseSensitive: params.caseSensitive,
});
}
// =============================================================================
// Attachment helpers
// =============================================================================
function isSafeAttachmentFileName(fileName: string): boolean {
if (!fileName.trim()) {
return false;
}
if (fileName !== path.basename(fileName)) {
return false;
}
if (/[\0-\x1f<>:"/\\|?*]/.test(fileName)) {
return false;
}
if (fileName === "." || fileName === "..") {
return false;
}
return true;
}
function guessMimeType(fileName: string): string {
const extension = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
".txt": "text/plain",
".md": "text/markdown",
".markdown": "text/markdown",
".json": "application/json",
".jsonl": "application/x-ndjson",
".csv": "text/csv",
".html": "text/html",
".htm": "text/html",
".xml": "application/xml",
".yaml": "application/yaml",
".yml": "application/yaml",
".pdf": "application/pdf",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".7z": "application/x-7z-compressed",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".flac": "audio/flac",
".wav": "audio/wav",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
};
return mimeTypes[extension] ?? "application/octet-stream";
}
// =============================================================================
// Chunked write helpers
// =============================================================================
function parsePositiveInteger(value: AiJsonValue | null | undefined, fieldName: string): number {
const numberValue =
typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isSafeInteger(numberValue) || numberValue < 1) {
throw new Error(`${fieldName} must be a positive integer.`);
}
return numberValue;
}
function getFileWriteSession(sessionIdInput: AiJsonValue | null | undefined): FileWriteSession {
const sessionId = asNonEmptyString(sessionIdInput);
if (!sessionId) {
throw new Error("sessionId is required.");
}
const session = fileWriteSessions.get(sessionId);
if (!session) {
throw new Error(`File write session not found or expired: ${sessionId}`);
}
return session;
}
async function cleanupExpiredFileWriteSessions(): Promise<void> {
const now = Date.now();
for (const [sessionId, session] of fileWriteSessions.entries()) {
if (now - session.updatedAtMs <= MAX_STREAM_WRITE_IDLE_MS) {
continue;
}
fileWriteSessions.delete(sessionId);
await fs.promises.rm(session.tempAbsolutePath, {
force: true,
});
}
}