diff --git a/src/ai/text-to-speech.ts b/src/ai/text-to-speech.ts index ad76dc9..e6c62fc 100644 --- a/src/ai/text-to-speech.ts +++ b/src/ai/text-to-speech.ts @@ -374,7 +374,7 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt reply_parameters: {message_id: sourceMessage.message_id}, }); } finally { - destroyUpload(upload); + // destroyUpload(upload); } }, {method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type} diff --git a/src/ai/tool-mappers.ts b/src/ai/tool-mappers.ts index 2c58c6c..080d9dd 100644 --- a/src/ai/tool-mappers.ts +++ b/src/ai/tool-mappers.ts @@ -1,13 +1,13 @@ import {AiTool} from "./tool-types"; import {AiProvider} from "../model/ai-provider"; import {getTools} from "./tools/registry"; -import {WEB_SEARCH_TOOL_NAME} from "./tools/brave-search"; +import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search"; import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator"; export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral"; -export function getOllamaTools(): AiTool[] { - return getTools(); +export function getOllamaTools(forCreator?: boolean): AiTool[] { + return getTools(forCreator); } const openAiForbiddenTools = [ @@ -19,8 +19,8 @@ function allowedOpenAiTool(tool: AiTool): boolean { return !openAiForbiddenTools.includes(tool.function.name) } -export function getOpenAITools(): AiTool[] { - return getTools().filter(allowedOpenAiTool).map(tool => ({ +export function getOpenAITools(forCreator?: boolean): AiTool[] { + return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({ type: "function", function: tool.function, })); @@ -43,8 +43,8 @@ export type OpenAiCodeInterpreterTool = { } | string; }; -export function getOpenAIResponsesTools(): OpenAiResponseTool[] { - return getTools().filter(allowedOpenAiTool).map(tool => ({ +export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] { + return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({ type: "function", name: tool.function.name, description: tool.function.description, @@ -62,8 +62,8 @@ export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool { }; } -export function getMistralTools(): AiTool[] { - return getTools().map(tool => ({ +export function getMistralTools(forCreator?: boolean): AiTool[] { + return getTools(forCreator).map(tool => ({ type: "function", function: tool.function, })); @@ -77,8 +77,8 @@ export type GeminiTool = { }>; } -export function getGeminiTools(): GeminiTool[] { - const functionDeclarations = getTools().map(tool => ({ +export function getGeminiTools(forCreator?: boolean): GeminiTool[] { + const functionDeclarations = getTools(forCreator).map(tool => ({ name: tool.function.name, description: tool.function.description, parametersJsonSchema: tool.function.parameters, @@ -87,15 +87,15 @@ export function getGeminiTools(): GeminiTool[] { return functionDeclarations.length ? [{functionDeclarations}] : []; } -export function getProviderTools(provider: AiProvider): AiTool[] { +export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] { switch (provider) { case AiProvider.OLLAMA: - return getOllamaTools(); + return getOllamaTools(forCreator); case AiProvider.GEMINI: - return getTools(); + return getTools(forCreator); case AiProvider.MISTRAL: - return getMistralTools(); + return getMistralTools(forCreator); case AiProvider.OPENAI: - return getOpenAITools(); + return getOpenAITools(forCreator); } } diff --git a/src/ai/tools/file-system.ts b/src/ai/tools/file-system.ts deleted file mode 100644 index 27cf8de..0000000 --- a/src/ai/tools/file-system.ts +++ /dev/null @@ -1,852 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import {Environment} from "../../common/environment"; -import {AiTool} from "../tool-types"; -import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits"; -import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils"; - -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 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 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 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 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 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 read_file for reading files.", - "- Use list_directory for reading directories.", - "- Use create_file for creating files.", - "- Use create_directory for creating directories.", - "- Use update_file for replacing, appending or prepending file content.", - "- Use rename_path for renaming or moving files/directories inside the root.", - "- Use delete_path for deleting files/directories inside the root.", - "" -].join("\n"); - -const requireFileToolsRootDir = () => Environment.FILE_TOOLS_ROOT_DIR; - -async function ensureFileToolsRootExists(): Promise { - await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true}); - - const stat = await fs.promises.stat(requireFileToolsRootDir()); - - if (!stat.isDirectory()) { - throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`); - } -} - -function resolveSafeToolPath(inputPath: unknown, fallback = "."): { - absolutePath: string; - relativePath: string; -} { - 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(requireFileToolsRootDir(), normalizedInputPath); - const relativePath = path.relative(requireFileToolsRootDir(), absolutePath); - - if ( - relativePath.startsWith("..") || - path.isAbsolute(relativePath) - ) { - throw new Error("Path escapes the root directory. Going up is not allowed."); - } - - return { - absolutePath, - relativePath: relativePath || ".", - }; -} - -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, - options?: { - allowMissingTail?: boolean; - } -): Promise { - await ensureFileToolsRootExists(); - - const relativePath = path.relative(requireFileToolsRootDir(), absolutePath); - - if (!relativePath || relativePath === ".") { - return; - } - - const parts = relativePath.split(path.sep).filter(Boolean); - - let currentPath = requireFileToolsRootDir(); - - 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 (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) { - return; - } - - throw e; - } - } -} - -async function pathExists(absolutePath: string): Promise { - try { - await fs.promises.lstat(absolutePath); - return true; - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") return false; - throw e; - } -} - -function assertNotRoot(relativePath: string): void { - if (relativePath === ".") { - throw new Error("Operation on the root directory itself is not allowed."); - } -} - -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"; -} - -export async function readFile(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - await assertNoSymlinkInPath(absolutePath); - - 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?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, "."); - - await assertNoSymlinkInPath(absolutePath); - - 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 createFile(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - 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, {allowMissingTail: true}); - await fs.promises.mkdir(parentPath, {recursive: true}); - } else { - await assertNoSymlinkInPath(parentPath); - } - - 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, - }; -} - -type CopyPathStats = { - entries: number; - totalBytes: number; -}; - -async function copyPathRecursive(params: { - sourceAbsolutePath: string; - targetAbsolutePath: string; - overwrite: boolean; - stats: CopyPathStats; -}): Promise { - 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(requireFileToolsRootDir(), 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, - }); - } - - return; - } - - throw new Error("Only files and directories can be copied."); -} - -export async function copyPath(args?: Record) { - const source = resolveSafeToolPath(args?.sourcePath); - const target = resolveSafeToolPath(args?.targetPath); - - assertNotRoot(source.relativePath); - assertNotRoot(target.relativePath); - - await assertNoSymlinkInPath(source.absolutePath); - - 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, { - allowMissingTail: true, - }); - - await fs.promises.mkdir(targetParentPath, { - recursive: true, - }); - - await assertNoSymlinkInPath(targetParentPath); - } else { - await assertNoSymlinkInPath(targetParentPath); - } - - const stats: CopyPathStats = { - entries: 0, - totalBytes: 0, - }; - - await copyPathRecursive({ - sourceAbsolutePath: source.absolutePath, - targetAbsolutePath: target.absolutePath, - overwrite, - stats, - }); - - return { - ok: true, - from: source.relativePath, - to: target.relativePath, - recursive, - overwrite, - entriesCopied: stats.entries, - bytesCopied: stats.totalBytes, - }; -} - -export async function createDirectory(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - const recursive = asBoolean(args?.recursive, true); - - await assertNoSymlinkInPath(absolutePath, { - allowMissingTail: true, - }); - - await fs.promises.mkdir(absolutePath, { - recursive, - }); - - return { - ok: true, - path: relativePath, - recursive, - }; -} - -export async function updateFile(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - 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); - - const exists = await pathExists(absolutePath); - - if (!exists && !createIfMissing) { - throw new Error(`File does not exist: ${relativePath}`); - } - - if (exists) { - await assertNoSymlinkInPath(absolutePath); - - 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 renamePath(args?: Record) { - const source = resolveSafeToolPath(args?.sourcePath); - const target = resolveSafeToolPath(args?.targetPath); - - assertNotRoot(source.relativePath); - assertNotRoot(target.relativePath); - - await assertNoSymlinkInPath(source.absolutePath); - - 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, {allowMissingTail: true}); - await fs.promises.mkdir(targetParentPath, {recursive: true}); - } else { - await assertNoSymlinkInPath(targetParentPath); - } - - 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?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - assertNotRoot(relativePath); - - await assertNoSymlinkInPath(absolutePath); - - 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, - }; -} diff --git a/src/ai/tools/files.ts b/src/ai/tools/files.ts new file mode 100644 index 0000000..b18a991 --- /dev/null +++ b/src/ai/tools/files.ts @@ -0,0 +1,2433 @@ +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 {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(); + +// ============================================================================= +// 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 unknown 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record, +): Promise { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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?: Record) { + 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: unknown): 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?: unknown): 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 { + 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: unknown, + fallback = ".", + userIdInput?: unknown, +): { + 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 { + 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: unknown) { + if ( + (error as NodeJS.ErrnoException).code === "ENOENT" && + options?.allowMissingTail + ) { + return; + } + + throw error; + } + } +} + +async function pathExists(absolutePath: string): Promise { + try { + await fs.promises.lstat(absolutePath); + return true; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + + throw error; + } +} + +async function writeTextFileAtomic( + absolutePath: string, + content: string, + rootDir: string +): Promise { + 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 { + 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 { + 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: unknown): 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 Record; + 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: unknown): 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 = { + ".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: unknown, 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: unknown): 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 { + 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, + }); + } +} diff --git a/src/ai/tools/limits.ts b/src/ai/tools/limits.ts index d4bbf02..2539d60 100644 --- a/src/ai/tools/limits.ts +++ b/src/ai/tools/limits.ts @@ -3,3 +3,15 @@ export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024; export const MAX_DIRECTORY_ENTRIES = 200; export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024; export const MAX_COPY_ENTRIES = 500; +export const MAX_PATCH_OPERATIONS = 20; +export const MAX_PATCH_SEARCH_BYTES = 64 * 1024; +export const MAX_PATCH_REPLACE_BYTES = 256 * 1024; +export const MAX_PATCH_PREVIEW_CHARS = 6000; +export const MAX_FILE_SEARCH_ENTRIES = 5000; +export const MAX_FILE_SEARCH_RESULTS = 100; +export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024; +export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300; +export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024; +export const MAX_STREAM_WRITE_SESSIONS = 20; +export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000; +export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024; \ No newline at end of file diff --git a/src/ai/tools/market-rates.ts b/src/ai/tools/market-rates.ts index 13dfc33..32a1fed 100644 --- a/src/ai/tools/market-rates.ts +++ b/src/ai/tools/market-rates.ts @@ -20,7 +20,7 @@ export const getFinancialMarketData = { }, } satisfies AiTool; -export const financialMarketDataToolPrompt = [ +export const getFinancialMarketDataToolPrompt = [ "Currency rates tool rules:", `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`, `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`, diff --git a/src/ai/tools/registry.ts b/src/ai/tools/registry.ts index c42cc38..ee557f3 100644 --- a/src/ai/tools/registry.ts +++ b/src/ai/tools/registry.ts @@ -1,18 +1,22 @@ import {Environment} from "../../common/environment"; import {AiTool} from "../tool-types"; -import {braveSearchTool, webSearch} from "./brave-search"; +import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search"; import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime"; import {shellExecute, shellExecuteTool} from "./shell"; import {ToolHandler} from "./types"; import {getWeather, getWeatherTool} from "./weather"; import { - financialMarketDataToolPrompt, GET_FINANCIAL_MARKET_DATA_TOOL_NAME, getFinancialMarketData, + getFinancialMarketDataToolPrompt, getMarketRates } from "./market-rates"; import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator"; import { + beginFileWrite, + beginFileWriteTool, + cancelFileWrite, + cancelFileWriteTool, copyPath, copyPathTool, createDirectory, @@ -21,70 +25,81 @@ import { createFileTool, deletePath, deletePathTool, + editFilePatch, + editFilePatchTool, + fileToolsToolPrompt, + finishFileWrite, + finishFileWriteTool, listDirectory, listDirectoryTool, readFile, readFileTool, renamePath, renamePathTool, + searchFiles, + searchFilesTool, + sendFileAsAttachment, + sendFileAsAttachmentTool, updateFile, - updateFileTool -} from "./file-system"; -import {createNote, createNoteTool} from "./create-note"; -import { - deleteNote, - deleteNoteTool, - getNoteContent, - getNoteContentTool, - listNotes, - listNotesTool, - sendNoteAsFile, - sendNoteAsFileTool, - updateNoteContent, - updateNoteContentTool -} from "./notes"; -import {searchNotes, searchNotesTool} from "./search-notes"; + updateFileTool, + writeFileChunk, + writeFileChunkTool +} from "./files"; -export const defaultFileTools: AiTool[] = [ +export const defaultTools: AiTool[] = [ getCurrentDateTimeTool, getFinancialMarketData, ] -export const fileSystemTools: AiTool[] = [ +export const fileTools = [ readFileTool, listDirectoryTool, - createFileTool, - createDirectoryTool, - updateFileTool, - renamePathTool, - copyPathTool, - deletePathTool, -]; + searchFilesTool, -export const notesFileTools: AiTool[] = [ - createNoteTool, - listNotesTool, - getNoteContentTool, - updateNoteContentTool, - deleteNoteTool, - sendNoteAsFileTool, - searchNotesTool -] + createFileTool, + beginFileWriteTool, + writeFileChunkTool, + finishFileWriteTool, + cancelFileWriteTool, + + sendFileAsAttachmentTool, + + createDirectoryTool, + copyPathTool, + updateFileTool, + editFilePatchTool, + renamePathTool, + deletePathTool, +] satisfies AiTool[]; + +// export const notesFileTools: AiTool[] = [ +// createNoteTool, +// listNotesTool, +// getNoteContentTool, +// updateNoteContentTool, +// deleteNoteTool, +// sendNoteAsFileTool, +// searchNotesTool +// ] export const getTools = (forCreator?: boolean) => { const tools: AiTool[] = [ - ...defaultFileTools, - ...notesFileTools + ...defaultTools, + // ...notesFileTools ]; if (Environment.BRAVE_SEARCH_API_KEY) { - tools.push(braveSearchTool); + tools.push(webSearchTool); } if (Environment.OPEN_WEATHER_MAP_API_KEY) { tools.push(getWeatherTool); } + if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) { + tools.push(...fileTools); + } + if (forCreator) { if (Environment.ENABLE_PYTHON_INTERPRETER) { tools.push(pythonInterpreterTool); @@ -93,70 +108,58 @@ export const getTools = (forCreator?: boolean) => { if (Environment.ENABLE_UNSAFE_EVAL) { tools.push(shellExecuteTool); } - - if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) { - tools.push(...fileSystemTools); - } } return tools; }; +export const fileToolHandlers = { + read_file: readFile, + list_directory: listDirectory, + search_files: searchFiles, + + create_file: createFile, + begin_file_write: beginFileWrite, + write_file_chunk: writeFileChunk, + finish_file_write: finishFileWrite, + cancel_file_write: cancelFileWrite, + + send_file_as_attachment: sendFileAsAttachment, + + create_directory: createDirectory, + copy_path: copyPath, + update_file: updateFile, + edit_file_patch: editFilePatch, + rename_path: renamePath, + delete_path: deletePath, +}; + export const getToolHandlers = () => { let handlers: Record = { get_datetime: getCurrentDateTime, get_financial_market_data: getMarketRates, - create_note: createNote, - list_notes: listNotes, - get_note_content: getNoteContent, - update_note_content: updateNoteContent, - delete_note: deleteNote, - send_note_as_file: sendNoteAsFile, - search_notes: searchNotes + + // create_note: createNote, + // list_notes: listNotes, + // get_note_content: getNoteContent, + // update_note_content: updateNoteContent, + // delete_note: deleteNote, + // send_note_as_file: sendNoteAsFile, + // search_notes: searchNotes, + + ...fileToolHandlers, + + + python_interpreter: runPythonInterpreter, + + shell_execute: shellExecute, + + web_search: webSearch, + + get_weather: getWeather, + }; - if (Environment.ENABLE_PYTHON_INTERPRETER) { - handlers = { - python_interpreter: runPythonInterpreter, - ...handlers - }; - } - - if (Environment.ENABLE_UNSAFE_EVAL) { - handlers = { - shell_execute: shellExecute, - ...handlers, - }; - } - - if (Environment.BRAVE_SEARCH_API_KEY) { - handlers = { - web_search: webSearch, - ...handlers, - }; - } - - if (Environment.OPEN_WEATHER_MAP_API_KEY) { - handlers = { - get_weather: getWeather, - ...handlers, - }; - } - - if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) { - handlers = { - read_file: readFile, - list_directory: listDirectory, - create_file: createFile, - create_directory: createDirectory, - update_file: updateFile, - rename_path: renamePath, - copy_path: copyPath, - delete_path: deletePath, - ...handlers, - }; - } - return handlers; }; @@ -164,9 +167,18 @@ export function getToolPrompts(toolNames: string[]): string[] { const prompts: string[] = []; for (const toolName of toolNames) { + if (!prompts.includes(fileToolsToolPrompt) && + fileTools.map(t => t.function.name).includes(toolName)) { + prompts.push(fileToolsToolPrompt); + continue; + } + switch (toolName) { case GET_FINANCIAL_MARKET_DATA_TOOL_NAME: - prompts.push(financialMarketDataToolPrompt); + prompts.push(getFinancialMarketDataToolPrompt); + break; + case WEB_SEARCH_TOOL_NAME: + prompts.push(webSearchToolPrompt); break; default: break; diff --git a/src/ai/tools/runtime.ts b/src/ai/tools/runtime.ts index 4403251..5ce0f73 100644 --- a/src/ai/tools/runtime.ts +++ b/src/ai/tools/runtime.ts @@ -15,6 +15,7 @@ function stringifyToolResult(result: unknown): string { } export async function executeToolCall( + userId: number | undefined | null, name: string, args?: unknown, context: ToolRuntimeContext = {}, @@ -31,7 +32,7 @@ export async function executeToolCall( try { if (name === PYTHON_INTERPRETER_TOOL_NAME) { - const result = await runPythonInterpreter(normalizeToolArguments(args), { + const result = await runPythonInterpreter(normalizeToolArguments(args, userId), { executionTimeoutMs: 8_000, syntaxTimeoutMs: 3_000, maxCodeChars: 100_000, @@ -45,7 +46,8 @@ export async function executeToolCall( return s; } - const result = await handler(normalizeToolArguments(args)); + const arguments1 = normalizeToolArguments(args, userId); + const result = await handler(arguments1); const s = stringifyToolResult(result); logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)}); return s; diff --git a/src/ai/tools/utils.ts b/src/ai/tools/utils.ts index b10cea8..a8b9241 100644 --- a/src/ai/tools/utils.ts +++ b/src/ai/tools/utils.ts @@ -9,7 +9,7 @@ export function asNonEmptyString(value: unknown): string | undefined { : undefined; } -export function normalizeToolArguments(args: unknown): Record { +export function normalizeToolArguments(args: unknown, userId?: number | null): Record { if (!args) return {}; if (typeof args === "string") { @@ -29,7 +29,11 @@ export function normalizeToolArguments(args: unknown): Record { } if (typeof args === "object" && !Array.isArray(args)) { - return args as Record; + const userIdObject = userId ? {"userId": userId} : {}; + return { + ...args as Record, + ...userIdObject as Record + } } return {}; diff --git a/src/ai/tools/brave-search.ts b/src/ai/tools/web-search.ts similarity index 99% rename from src/ai/tools/brave-search.ts rename to src/ai/tools/web-search.ts index 81a48aa..1e5903f 100644 --- a/src/ai/tools/brave-search.ts +++ b/src/ai/tools/web-search.ts @@ -92,7 +92,7 @@ type BraveSearchApiResponse = { export const WEB_SEARCH_TOOL_NAME = "web_search"; -export const braveSearchTool = { +export const webSearchTool = { type: "function", function: { name: WEB_SEARCH_TOOL_NAME, @@ -163,7 +163,7 @@ export const braveSearchTool = { }, } satisfies AiTool; -export const braveSearchToolPrompt = [ +export const webSearchToolPrompt = [ "Brave Search tool rules:", "- You have access to `web_search`.", "- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.", diff --git a/src/ai/unified-ai-runner.gemini.ts b/src/ai/unified-ai-runner.gemini.ts index 723a690..717da86 100644 --- a/src/ai/unified-ai-runner.gemini.ts +++ b/src/ai/unified-ai-runner.gemini.ts @@ -19,6 +19,7 @@ import { ToolCallData, ToolExecutionMemory } from "./unified-ai-runner.shared"; +import {Message} from "typescript-telegram-bot-api"; function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string { if (typeof response.text === "string") return response.text; @@ -82,6 +83,7 @@ function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[], } export async function runGemini( + msg: Message, messages: GeminiMessage[], streamMessage: TelegramStreamMessage, signal: AbortSignal, @@ -143,7 +145,7 @@ export async function runGemini( }); if (!calls.length) return; - appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory)); + appendGeminiToolRound(messages, calls, await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory)); continue; } @@ -166,7 +168,7 @@ export async function runGemini( calls: calls.map(aiLogToolCall), }); if (!calls.length) return; - appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory)); + appendGeminiToolRound(messages, calls, await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory)); } } diff --git a/src/ai/unified-ai-runner.mistral.ts b/src/ai/unified-ai-runner.mistral.ts index 2c1ad3c..58e2a91 100644 --- a/src/ai/unified-ai-runner.mistral.ts +++ b/src/ai/unified-ai-runner.mistral.ts @@ -1,4 +1,3 @@ -// Mistral provider runner extracted from unified-ai-runner.ts. import {Environment} from "../common/environment"; import {getMistralTools} from "./tool-mappers"; import {TelegramStreamMessage} from "./telegram-stream-message"; @@ -7,9 +6,24 @@ import {MistralChatMessage} from "./mistral-chat-message"; import {createMistralClient} from "./ai-runtime-target"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; -import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared"; +import { + contentFromMistralDelta, + executeToolBatch, + MAX_TOOL_ROUNDS, + MistralDeltaLike, + MistralDocumentReference, + mistralToolCalls, + normalizeMistralToolCalls, + roundStatus, + RuntimeConfigSnapshot, + StreamingToolCallAccumulator, + ToolCallData, + ToolExecutionMemory +} from "./unified-ai-runner.shared"; +import {Message} from "typescript-telegram-bot-api"; export async function runMistral( + msg: Message, messages: MistralChatMessage[], documents: MistralDocumentReference[], streamMessage: TelegramStreamMessage, @@ -43,14 +57,14 @@ export async function runMistral( const request = { model: config.mistralChatTarget.model, messages, - tools: getMistralTools(), + tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID), documents: documents } as unknown as Parameters[0]; const response = await mistralAi.chat.complete(request, {signal}); - const msg = response.choices?.[0]?.message; - const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? ""); + const message = response.choices?.[0]?.message; + const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? ""); streamMessage.append(text); - const calls = normalizeMistralToolCalls(mistralToolCalls(msg)); + const calls = normalizeMistralToolCalls(mistralToolCalls(message)); aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", { round, duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), @@ -66,7 +80,7 @@ export async function runMistral( function: {name: call.name, arguments: call.argumentsText}, })), }); - const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); + const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); for (const [index, call] of calls.entries()) { messages.push({ role: "tool", @@ -81,7 +95,7 @@ export async function runMistral( const request = { model: config.mistralChatTarget.model, messages, - tools: getMistralTools(), + tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID), documents: documents } as unknown as Parameters[0]; const streamResponse = await mistralAi.chat.stream(request, {signal}); @@ -119,7 +133,7 @@ export async function runMistral( content: roundText, toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}})) }); - const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); + const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); for (const [index, call] of calls.entries()) { messages.push({ role: "tool", @@ -129,9 +143,4 @@ export async function runMistral( }); } } -} - - -export class MistralProviderRunner { - static run = runMistral; -} +} \ No newline at end of file diff --git a/src/ai/unified-ai-runner.ollama.ts b/src/ai/unified-ai-runner.ollama.ts index 7928bed..67ea6df 100644 --- a/src/ai/unified-ai-runner.ollama.ts +++ b/src/ai/unified-ai-runner.ollama.ts @@ -1,5 +1,4 @@ // Ollama provider runner extracted from unified-ai-runner.ts. -import {Message} from "typescript-telegram-bot-api"; import * as fs from "node:fs"; import path from "node:path"; import {Environment} from "../common/environment"; @@ -11,9 +10,6 @@ import {ChatMessage} from "./chat-messages-types"; import {ChatRequest, Tool} from "ollama"; import {ToolRuntimeContext} from "./tools/runtime"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; -import {getCurrentDateTimeTool} from "./tools/datetime"; -import {getFinancialMarketData} from "./tools/market-rates"; -import {getWeatherTool} from "./tools/weather"; import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; import {createOllamaClient} from "./ai-runtime-target"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; @@ -40,17 +36,10 @@ import { } from "./unified-ai-runner.shared"; import {latestUserTextFromOllamaMessages, OllamaToolRanker} from "./unified-ai-runner.tool-ranker"; import {getToolPrompts} from "./tools/registry"; -import {createNoteTool} from "./tools/create-note"; -import { - deleteNoteTool, - getNoteContentTool, - GetNoteFileResult, - GetNoteFileResultSchema, - listNotesTool, - sendNoteAsFileTool, - updateNoteContentTool -} from "./tools/notes"; -import {searchNotesTool} from "./tools/search-notes"; +import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes"; +import {getModelCapabilities} from "./provider-model-runtime"; +import {AiProvider} from "../model/ai-provider"; +import {Message} from "typescript-telegram-bot-api"; export async function runOllama( msg: Message, @@ -64,7 +53,6 @@ export async function runOllama( toolContext: ToolRuntimeContext, contextSize?: number, ): Promise { - const fromId = msg.from?.id; const runnerStartedAt = Date.now(); const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0); @@ -192,68 +180,57 @@ export async function runOllama( }; let activeToolNames: string[] = []; - // if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { - const availableOllamaTools: Tool[] = - fromId !== Environment.CREATOR_ID ? [ - getCurrentDateTimeTool, - getFinancialMarketData, - getWeatherTool, - createNoteTool, - listNotesTool, - getNoteContentTool, - updateNoteContentTool, - deleteNoteTool, - sendNoteAsFileTool, - searchNotesTool - ] : getOllamaTools() as Tool[]; + if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { + const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[]; - aiLog("debug", "ollama.tools.available", { - round, - tools: allToolSchemaNames(availableOllamaTools), - rankerEnabled: !!config.ollamaToolRankerTarget, - }); + aiLog("debug", "ollama.tools.available", { + round, + tools: allToolSchemaNames(availableOllamaTools), + rankerEnabled: !!config.ollamaToolRankerTarget, + }); - const rankerSelection = await new OllamaToolRanker(config).selectTools({ - userQuery: latestUserTextFromOllamaMessages(messages), - availableTools: availableOllamaTools, - round, - signal, - }); + const rankerSelection = await new OllamaToolRanker(config).selectTools({ + userQuery: latestUserTextFromOllamaMessages(messages), + availableTools: availableOllamaTools, + round, + signal, + }); - activeToolNames = rankerSelection.tools.map(t => t.function.name ?? ""); - if (rankerSelection.tools.length > 0) { - request.tools = [...rankerSelection.tools, ...rankerSelection.tools]; - request.options = { - ...request.options, - temperature: 0 + const filteredTools = [...new Set(rankerSelection.tools)]; + activeToolNames = filteredTools.map(t => t.function.name ?? ""); + if (filteredTools.length > 0) { + request.tools = [...filteredTools]; + request.options = { + ...request.options, + temperature: 0 + } + + const newMessage = messages[messages.length - 1]; + if (newMessage) { + newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", "); + } + + const systemMessage = messages.find(m => m.role === "system"); + if (systemMessage) { + systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n"); + } + + request.model = config.ollamaToolTarget.model; + } else { + delete request.tools; } - const newMessage = messages[messages.length - 1]; - if (newMessage) { - newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", "); - } + // TODO: 14.05.2026, Danil Nikolaev: check if model supports tools - const systemMessage = messages.find(m => m.role === "system"); - if (systemMessage) { - systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n"); - } - request.model = config.ollamaToolTarget.model; - } else { - delete request.tools; + aiLog("debug", "ollama.tools.selected", { + round, + tools: activeToolNames, + count: activeToolNames.length, + usedRanker: rankerSelection.usedRanker, + }); } - // TODO: 14.05.2026, Danil Nikolaev: check if model supports tools - - - aiLog("debug", "ollama.tools.selected", { - round, - tools: activeToolNames, - count: activeToolNames.length, - usedRanker: rankerSelection.usedRanker, - }); - // } - if (!stream) { const response = await ollama.chat({ ...request, @@ -316,7 +293,7 @@ export async function runOllama( appendOllamaToolResults( messages, calls, - await executeToolBatch(calls, streamMessage, toolContext, toolMemory), + await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory), ); continue; @@ -425,7 +402,7 @@ export async function runOllama( })), }); - const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); + const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; diff --git a/src/ai/unified-ai-runner.openai.ts b/src/ai/unified-ai-runner.openai.ts index bb00ff5..34a9dff 100644 --- a/src/ai/unified-ai-runner.openai.ts +++ b/src/ai/unified-ai-runner.openai.ts @@ -1,7 +1,6 @@ // OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts. import {Message} from "typescript-telegram-bot-api"; import {Environment} from "../common/environment"; -import {getOpenAITools} from "./tool-mappers"; import {TelegramStreamMessage} from "./telegram-stream-message"; import {ToolRuntimeContext} from "./tools/runtime"; import {OpenAIChatMessage} from "./openai-chat-message"; @@ -11,45 +10,35 @@ import type { ResponseInputItem, ResponseStreamEvent } from "openai/resources/responses/responses"; -import type { - ChatCompletionCreateParamsNonStreaming, - ChatCompletionCreateParamsStreaming -} from "openai/resources/chat/completions"; -import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target"; +import {createOpenAiClient} from "./ai-runtime-target"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import { AsyncIterableStream, - collectOpenAiResponseFunctionCalls, + buildSystemInstruction, collectOpenAiResponseCodeInterpreterCalls, + collectOpenAiResponseFunctionCalls, collectOpenAiResponseImages, collectOpenAiResponseText, executeToolBatch, getOpenAIResponsesToolsWithImage, - isRecord, MAX_TOOL_ROUNDS, OPENAI_IMAGE_PARTIALS, - OpenAiChatCompletionResponseLike, - OpenAiChatCompletionStreamChunkLike, - OpenAiChatToolCallLike, - OpenAiCompatibleChatMessage, - OpenAiCompatibleContentPart, openAiResponseItemCallId, OpenAiResponseLike, OpenAiResponseOutputItem, - roundStatus, RuntimeConfigSnapshot, safeJsonParseObject, showOpenAiGeneratedImage, - StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory } from "./unified-ai-runner.shared"; -import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-as-file"; -import {bot, notesDir} from "../index"; +import {bot, filesDir} from "../index"; import fs from "node:fs"; import path from "node:path"; import {logError} from "../util/utils"; +import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files"; +import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings"; export async function runOpenAi( msg: Message, @@ -57,19 +46,16 @@ export async function runOpenAi( streamMessage: TelegramStreamMessage, signal: AbortSignal, stream: boolean, - firstRoundStatus: string, sourceMessage: Message, config: RuntimeConfigSnapshot, toolContext: ToolRuntimeContext, - think?: boolean ): Promise { - // TODO: 13.05.2026: remove - firstRoundStatus; - think; const runnerStartedAt = Date.now(); let responseInput: unknown[] = [...messages]; const openAi = createOpenAiClient(config.openAiChatTarget); + const systemPrompt = buildSystemInstruction(config, DEFAULT_AI_RESPONSE_LANGUAGE, false); + aiLog("info", "openai.run.start", { stream, target: aiLogProviderTarget(config.openAiChatTarget), @@ -89,8 +75,8 @@ export async function runOpenAi( const request: ResponseCreateParamsNonStreaming = { model: config.openAiChatTarget.model, input: responseInput as ResponseInputItem[], - tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"], - instructions: config.systemPrompt, + tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsNonStreaming["tools"], + instructions: systemPrompt, }; const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike; @@ -146,38 +132,26 @@ export async function runOpenAi( name: call.name, argumentsText: call.argumentsText, })); - const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory); - - let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; - - for (const toolResult of toolResults) { - try { - const raw = JSON.parse(toolResult); - const res = GetNoteFileResultSchema.safeParse(raw); - - if (res.success && res.data.success) { - successGetNoteFileResult = res.data; - } - } catch { - // Not every tool result is JSON. - } - } - - if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { - await bot.sendDocument({ - chat_id: msg.chat.id, - reply_parameters: { - message_id: msg.message_id, - }, - document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)), - }).catch(logError); - } - + const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory); const toolOutputs = calls.map((call, index) => ({ type: "function_call_output" as const, call_id: call.callId, output: toolResults[index] ?? "", })); + + const uploadFilesResult = await tryToUploadFiles(msg, toolResults); + if (uploadFilesResult.found) { + if (!uploadFilesResult.uploaded) { + const old = toolOutputs[uploadFilesResult.toolIndex]; + delete toolOutputs[uploadFilesResult.toolIndex]; + toolOutputs.push({ + type: "function_call_output" as const, + call_id: old.call_id, + output: "Error: " + uploadFilesResult.error + }); + } + } + responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs]; continue; } @@ -187,8 +161,9 @@ export async function runOpenAi( model: config.openAiChatTarget.model, input: responseInput as ResponseInputItem[], stream: true, - tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"], - parallel_tool_calls: true + tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsStreaming["tools"], + parallel_tool_calls: true, + instructions: systemPrompt }; const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream; @@ -338,273 +313,313 @@ export async function runOpenAi( name: call.name, argumentsText: call.argumentsText, })); - const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory); - - let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; - - for (const toolResult of toolResults) { - try { - const raw = JSON.parse(toolResult); - const res = GetNoteFileResultSchema.safeParse(raw); - - if (res.success && res.data.success) { - successGetNoteFileResult = res.data; - } - } catch { - // Not every tool result is JSON. - } - } - - if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { - await bot.sendDocument({ - chat_id: msg.chat.id, - reply_parameters: { - message_id: msg.message_id, - }, - document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)), - }).catch(logError); - } - + const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory); const toolOutputs = calls.map((call, index) => ({ type: "function_call_output", call_id: call.callId, output: toolResults[index] ?? "", })); + + const uploadFilesResult = await tryToUploadFiles(msg, toolResults); + if (uploadFilesResult.found) { + if (!uploadFilesResult.uploaded) { + const old = toolOutputs[uploadFilesResult.toolIndex]; + delete toolOutputs[uploadFilesResult.toolIndex]; + toolOutputs.push({ + type: "function_call_output" as const, + call_id: old.call_id, + output: "Error: " + uploadFilesResult.error + }); + } + } + responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs]; } } - -function openAiResponseContentToText(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join(""); -} - -function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] { - return messages.map((message): OpenAiCompatibleChatMessage => { - if (message.role === "system" || message.role === "assistant") { - return { - role: message.role, - content: openAiResponseContentToText(message.content), - }; - } - - const content = Array.isArray(message.content) - ? message.content.map((part): OpenAiCompatibleContentPart => { - if (isRecord(part) && part.type === "input_image") { - return { - type: "image_url", - image_url: {url: String(part.image_url ?? "")}, - }; - } - - return { - type: "text", - text: isRecord(part) && typeof part.text === "string" ? part.text : "", - }; - }) - : message.content; - - return {role: "user", content}; - }); -} - -function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] { - return toolCalls.map((call, i) => ({ - id: call.id || `openai_chat_${Date.now()}_${i}`, - name: call.function?.name || call.name || "", - argumentsText: typeof call.function?.arguments === "string" - ? call.function.arguments - : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}), - })).filter(call => call.name); -} - -async function appendOpenAiChatToolResults( - messages: OpenAiCompatibleChatMessage[], - calls: ToolCallData[], - results: string[], -): Promise { - for (const [index, call] of calls.entries()) { - messages.push({ - role: "tool", - tool_call_id: call.id, - content: results[index] ?? "", - }); - } -} - -export async function runOpenAiCompatibleChat( +async function tryToUploadFiles( msg: Message, - messages: OpenAIChatMessage[], - streamMessage: TelegramStreamMessage, - signal: AbortSignal, - stream: boolean, - firstRoundStatus: string, - config: RuntimeConfigSnapshot, - toolContext: ToolRuntimeContext, -): Promise { - const runnerStartedAt = Date.now(); - const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget); - const chatMessages = openAiResponseMessagesToChatCompletions(messages); - const toolMemory: ToolExecutionMemory = new Map(); + toolResults: string[] +): Promise< + | { found: false } + | { found: true, uploaded: true } + | { found: boolean, uploaded: false, error: string, toolIndex: number } +> { + let sendFileAttachment: { + result: SendFileAttachmentResult & { success: true }, + toolIndex: number + } | null = null; - aiLog("info", "openai_compatible.run.start", { - stream, - target: aiLogProviderTarget(config.geminiChatTarget), - inputMessages: messages.length, - chatMessages: chatMessages.length, - hasToolInputFiles: !!toolContext.pythonInputFiles?.length, - }); - - for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { - const roundStartedAt = Date.now(); - aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream}); - streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? ""); - await streamMessage.flush(); - - if (!stream) { - const request: ChatCompletionCreateParamsNonStreaming = { - model: config.geminiChatTarget.model, - messages: chatMessages, - tools: getOpenAITools(), - // temperature: 0.6, - }; - const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike; - const message = response.choices?.[0]?.message; - streamMessage.append(message?.content ?? ""); - const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []); - aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", { - round, - duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), - textChars: message?.content?.length ?? 0, - calls: calls.map(aiLogToolCall), - }); - if (!calls.length) return; - - chatMessages.push({ - role: "assistant", - content: message?.content ?? "", - tool_calls: calls.map(call => ({ - id: call.id, - type: "function" as const, - function: { - name: call.name, - arguments: call.argumentsText, - }, - })), - }); - - const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); - - let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; - - for (const toolResult of toolResults) { - try { - const raw = JSON.parse(toolResult); - const res = GetNoteFileResultSchema.safeParse(raw); - - if (res.success && res.data.success) { - successGetNoteFileResult = res.data; - } - } catch { - // Not every tool result is JSON. - } - } - - if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { - await bot.sendDocument({ - chat_id: msg.chat.id, - reply_parameters: { - message_id: msg.message_id, - }, - document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)), - }).catch(logError); - } - - await appendOpenAiChatToolResults(chatMessages, calls, toolResults); - continue; - } - - const request: ChatCompletionCreateParamsStreaming = { - model: config.geminiChatTarget.model, - messages: chatMessages, - tools: getOpenAITools(), - // temperature: 0.6, - stream: true, - parallel_tool_calls: true - }; - const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream; - - aiLog("debug", "openai_compatible.stream.open", {round}); - // const streamToolCalls: OpenAiChatToolCallLike[] = []; - const roundTextStart = streamMessage.getText().length; - const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round); - let calls: ToolCallData[] = []; - - for await (const chunk of response) { - if (signal.aborted) throw new Error("Aborted"); - - const delta = chunk.choices?.[0]?.delta; - streamMessage.append(delta?.content ?? ""); - - if (delta?.tool_calls?.length) { - calls = toolCallAccumulator.add(delta.tool_calls); - streamMessage.setStatus(Environment.getUseToolText(calls)); - await streamMessage.flush(); - } - } - - // const calls = collectOpenAiChatStreamToolCalls(streamToolCalls); - aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", { - round, - duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), - textChars: streamMessage.getText().slice(roundTextStart).length, - calls: calls.map(aiLogToolCall), - }); - if (!calls.length) return; - - const roundText = streamMessage.getText().slice(roundTextStart); - chatMessages.push({ - role: "assistant", - content: roundText, - tool_calls: calls.map(call => ({ - id: call.id, - type: "function", - function: { - name: call.name, - arguments: call.argumentsText, - }, - })), - }); - - const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); - - let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; + let found = false; + try { for (const toolResult of toolResults) { - try { - const raw = JSON.parse(toolResult); - const res = GetNoteFileResultSchema.safeParse(raw); + const raw = JSON.parse(toolResult); + const res = SendFileAttachmentResultSchema.safeParse(raw); - if (res.success && res.data.success) { - successGetNoteFileResult = res.data; + if (res.success) { + found = true; + + if (res.data.success) { + sendFileAttachment = {result: res.data, toolIndex: toolResults.indexOf(toolResult)}; } - } catch { - // Not every tool result is JSON. } } - if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { - await bot.sendDocument({ - chat_id: msg.chat.id, - reply_parameters: { - message_id: msg.message_id, - }, - document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)), - }).catch(logError); + if (!found) { + return {found: false}; } - await appendOpenAiChatToolResults(chatMessages, calls, toolResults); + await bot.sendDocument({ + chat_id: msg.chat.id, + reply_parameters: { + message_id: msg.message_id, + }, + document: fs.createReadStream(path.join(filesDir, String(msg.from?.id), sendFileAttachment?.result?.attachment?.relativePath ?? "")), + }) + + return {found: true, uploaded: true}; + } catch (e: unknown) { + logError(e); + return { + found: found, + uploaded: false, + error: (e as any)?.message ?? "", + toolIndex: sendFileAttachment?.toolIndex ?? -1 + }; } } + +// function openAiResponseContentToText(content: unknown): string { +// if (typeof content === "string") return content; +// if (!Array.isArray(content)) return ""; +// return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join(""); +// } + +// function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] { +// return messages.map((message): OpenAiCompatibleChatMessage => { +// if (message.role === "system" || message.role === "assistant") { +// return { +// role: message.role, +// content: openAiResponseContentToText(message.content), +// }; +// } +// +// const content = Array.isArray(message.content) +// ? message.content.map((part): OpenAiCompatibleContentPart => { +// if (isRecord(part) && part.type === "input_image") { +// return { +// type: "image_url", +// image_url: {url: String(part.image_url ?? "")}, +// }; +// } +// +// return { +// type: "text", +// text: isRecord(part) && typeof part.text === "string" ? part.text : "", +// }; +// }) +// : message.content; +// +// return {role: "user", content}; +// }); +// } + +// function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] { +// return toolCalls.map((call, i) => ({ +// id: call.id || `openai_chat_${Date.now()}_${i}`, +// name: call.function?.name || call.name || "", +// argumentsText: typeof call.function?.arguments === "string" +// ? call.function.arguments +// : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}), +// })).filter(call => call.name); +// } + +// async function appendOpenAiChatToolResults( +// messages: OpenAiCompatibleChatMessage[], +// calls: ToolCallData[], +// results: string[], +// ): Promise { +// for (const [index, call] of calls.entries()) { +// messages.push({ +// role: "tool", +// tool_call_id: call.id, +// content: results[index] ?? "", +// }); +// } +// } + +// export async function runOpenAiCompatibleChat( +// msg: Message, +// messages: OpenAIChatMessage[], +// streamMessage: TelegramStreamMessage, +// signal: AbortSignal, +// stream: boolean, +// firstRoundStatus: string, +// config: RuntimeConfigSnapshot, +// toolContext: ToolRuntimeContext, +// ): Promise { +// const runnerStartedAt = Date.now(); +// const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget); +// const chatMessages = openAiResponseMessagesToChatCompletions(messages); +// const toolMemory: ToolExecutionMemory = new Map(); +// +// aiLog("info", "openai_compatible.run.start", { +// stream, +// target: aiLogProviderTarget(config.geminiChatTarget), +// inputMessages: messages.length, +// chatMessages: chatMessages.length, +// hasToolInputFiles: !!toolContext.pythonInputFiles?.length, +// }); +// +// for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { +// const roundStartedAt = Date.now(); +// aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream}); +// streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? ""); +// await streamMessage.flush(); +// +// if (!stream) { +// const request: ChatCompletionCreateParamsNonStreaming = { +// model: config.geminiChatTarget.model, +// messages: chatMessages, +// tools: getOpenAITools(msg.from?.id === Environment.CREATOR_ID), +// // temperature: 0.6, +// }; +// const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike; +// const message = response.choices?.[0]?.message; +// streamMessage.append(message?.content ?? ""); +// const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []); +// aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", { +// round, +// duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), +// textChars: message?.content?.length ?? 0, +// calls: calls.map(aiLogToolCall), +// }); +// if (!calls.length) return; +// +// chatMessages.push({ +// role: "assistant", +// content: message?.content ?? "", +// tool_calls: calls.map(call => ({ +// id: call.id, +// type: "function" as const, +// function: { +// name: call.name, +// arguments: call.argumentsText, +// }, +// })), +// }); +// +// const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); +// +// let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; +// +// for (const toolResult of toolResults) { +// try { +// const raw = JSON.parse(toolResult); +// const res = GetNoteFileResultSchema.safeParse(raw); +// +// if (res.success && res.data.success) { +// successGetNoteFileResult = res.data; +// } +// } catch { +// // Not every tool result is JSON. +// } +// } +// +// if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { +// await bot.sendDocument({ +// chat_id: msg.chat.id, +// reply_parameters: { +// message_id: msg.message_id, +// }, +// document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)), +// }).catch(logError); +// } +// +// await appendOpenAiChatToolResults(chatMessages, calls, toolResults); +// continue; +// } +// +// const request: ChatCompletionCreateParamsStreaming = { +// model: config.geminiChatTarget.model, +// messages: chatMessages, +// tools: getOpenAITools(msg.from?.id === Environment.CREATOR_ID), +// // temperature: 0.6, +// stream: true, +// parallel_tool_calls: true +// }; +// const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream; +// +// aiLog("debug", "openai_compatible.stream.open", {round}); +// // const streamToolCalls: OpenAiChatToolCallLike[] = []; +// const roundTextStart = streamMessage.getText().length; +// const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round); +// let calls: ToolCallData[] = []; +// +// for await (const chunk of response) { +// if (signal.aborted) throw new Error("Aborted"); +// +// const delta = chunk.choices?.[0]?.delta; +// streamMessage.append(delta?.content ?? ""); +// +// if (delta?.tool_calls?.length) { +// calls = toolCallAccumulator.add(delta.tool_calls); +// streamMessage.setStatus(Environment.getUseToolText(calls)); +// await streamMessage.flush(); +// } +// } +// +// // const calls = collectOpenAiChatStreamToolCalls(streamToolCalls); +// aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", { +// round, +// duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), +// textChars: streamMessage.getText().slice(roundTextStart).length, +// calls: calls.map(aiLogToolCall), +// }); +// if (!calls.length) return; +// +// const roundText = streamMessage.getText().slice(roundTextStart); +// chatMessages.push({ +// role: "assistant", +// content: roundText, +// tool_calls: calls.map(call => ({ +// id: call.id, +// type: "function", +// function: { +// name: call.name, +// arguments: call.argumentsText, +// }, +// })), +// }); +// +// const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); +// +// let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; +// +// for (const toolResult of toolResults) { +// try { +// const raw = JSON.parse(toolResult); +// const res = GetNoteFileResultSchema.safeParse(raw); +// +// if (res.success && res.data.success) { +// successGetNoteFileResult = res.data; +// } +// } catch { +// // Not every tool result is JSON. +// } +// } +// +// if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { +// await bot.sendDocument({ +// chat_id: msg.chat.id, +// reply_parameters: { +// message_id: msg.message_id, +// }, +// document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)), +// }).catch(logError); +// } +// +// await appendOpenAiChatToolResults(chatMessages, calls, toolResults); +// } +// } diff --git a/src/ai/unified-ai-runner.shared.ts b/src/ai/unified-ai-runner.shared.ts index 9845eda..44d7abb 100644 --- a/src/ai/unified-ai-runner.shared.ts +++ b/src/ai/unified-ai-runner.shared.ts @@ -1269,6 +1269,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes } export async function executeTool( + userId: number | undefined | null, toolCall: ToolCallData, message: TelegramStreamMessage, context: ToolRuntimeContext, @@ -1298,7 +1299,7 @@ export async function executeTool( } try { - const rawResult = await executeToolCall(toolCall.name, parsedArgs.args, context); + const rawResult = await executeToolCall(userId, toolCall.name, parsedArgs.args, context); const result = stringifyToolExecutionResult(rawResult); await sendToolArtifacts(toolCall, result, message); @@ -1367,16 +1368,18 @@ export async function runWithToolLocks(keys: string[], task: () => Promise } export async function executeScheduledTool( + userId: number | undefined | null, toolCall: ToolCallData, message: TelegramStreamMessage, context: ToolRuntimeContext, ): Promise { const keys = toolResourceKeys(toolCall); - if (!keys.length) return executeTool(toolCall, message, context); - return runWithToolLocks(keys, () => executeTool(toolCall, message, context)); + if (!keys.length) return executeTool(userId, toolCall, message, context); + return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context)); } export async function executeToolBatch( + userId: number | undefined | null, toolCalls: ToolCallData[], message: TelegramStreamMessage, context: ToolRuntimeContext, @@ -1417,7 +1420,7 @@ export async function executeToolBatch( message.setStatus(Environment.getUseToolText(statusCalls)); await message.flush(); - const resultText = await executeScheduledTool(toolCall, message, context); + const resultText = await executeScheduledTool(userId, toolCall, message, context); memory.set(signature, { count: (previous?.count ?? 0) + 1, @@ -1626,9 +1629,9 @@ export function allToolSchemaNames(tools: readonly unknown[]): string[] { return [...new Set(tools.flatMap(toolSchemaNames))]; } -export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot): Array { +export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot, forCreator?: boolean): Array { return [ - ...getOpenAIResponsesTools(), + ...getOpenAIResponsesTools(forCreator), getOpenAICodeInterpreterTool(), { type: "image_generation", @@ -1667,7 +1670,7 @@ export type OpenAiCodeInterpreterCall = { code: string | null; containerId: string; status: string; - outputs: Array<{type?: "logs" | "image"; logs?: string; url?: string}>; + outputs: Array<{ type?: "logs" | "image"; logs?: string; url?: string }>; }; export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] { diff --git a/src/ai/unified-ai-runner.ts b/src/ai/unified-ai-runner.ts index ee35b2e..2a11214 100644 --- a/src/ai/unified-ai-runner.ts +++ b/src/ai/unified-ai-runner.ts @@ -19,15 +19,13 @@ import {isTranscribableAudioDownload} from "./speech-to-text"; import {OpenAIChatMessage} from "./openai-chat-message"; import {MistralChatMessage} from "./mistral-chat-message"; import {OllamaChatMessage} from "./ollama-chat-message"; -import {GeminiMessage} from "./gemini-chat-message"; import {buildAiRegenerateCallbackData} from "./regenerate-callback"; -import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target"; +import {createOllamaClient} from "./ai-runtime-target"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger"; -import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai"; +import {runOpenAi} from "./unified-ai-runner.openai"; import {runOllama} from "./unified-ai-runner.ollama"; import {runMistral} from "./unified-ai-runner.mistral"; -import {runGemini} from "./unified-ai-runner.gemini"; import { AI_REQUEST_TIMEOUT_MS, appendTranscriptToChatMessages, @@ -35,6 +33,7 @@ import { collectRequestedAttachmentKinds, collectTextMessages, deleteMistralLibrary, + GeminiMessage, hasAudioAttachmentKind, initialStatus, isAbortError, @@ -51,6 +50,8 @@ import { transcribeAudioIfNeeded, UnifiedRunOptions } from "./unified-ai-runner.shared"; +import {runGemini} from "./unified-ai-runner.gemini"; +import {resolveTextToSpeechProviderForUser, sendSynthesizedSpeech, synthesizeSpeech} from "./text-to-speech"; export type {ToolCallData} from "./unified-ai-runner.shared"; export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; @@ -165,8 +166,7 @@ async function executeUnifiedAiRequest( switch (options.provider) { case AiProvider.OPENAI: - await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext, - !!options.think); + await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, options.msg, config, toolContext); break; case AiProvider.OLLAMA: const currentModel = config.ollamaChatTarget.model; @@ -179,14 +179,10 @@ async function executeUnifiedAiRequest( await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize); break; case AiProvider.MISTRAL: - await runMistral(chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); + await runMistral(options.msg, chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); break; case AiProvider.GEMINI: - if (getGeminiApiMode(config.geminiChatTarget) === "openai") { - await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); - } else { - await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); - } + await runGemini(options.msg, chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); break; } @@ -216,6 +212,24 @@ async function executeUnifiedAiRequest( } } +// noinspection JSUnusedLocalSymbols +// @ts-ignore +async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise { + if (!downloads.some(isTranscribableAudioDownload)) return; + if (!options.msg.from?.id) return; + + const trimmed = text.trim(); + if (!trimmed) return; + + try { + const provider = (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider; + const speech = await synthesizeSpeech({provider, text: trimmed}); + await sendSynthesizedSpeech(options.msg, speech); + } catch (e) { + logError(e); + } +} + export async function runUnifiedAi(options: UnifiedRunOptions): Promise { const startedAt = Date.now(); const config = snapshotRuntimeConfig(); diff --git a/src/index.ts b/src/index.ts index 946c18b..5c7daeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -190,6 +190,7 @@ export const videoDir = path.join(cacheDir, "video"); export const videoNotesDir = path.join(cacheDir, "video-note"); export const videoTempDir = path.join(videoDir, "temp"); +export const filesDir = path.join(Environment.DATA_PATH, "files"); export const NOTES_HEADER = "## Notes\n"; export const notesDir = path.join(Environment.DATA_PATH, "notes"); @@ -224,7 +225,7 @@ async function main() { dbPath: Environment.DB_PATH, }); - const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir]; + const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, filesDir]; dirsToCheck.forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, {recursive: true}); @@ -282,7 +283,12 @@ async function main() { const end = Date.now(); const diff = Math.abs(end - start); - logger.success("startup.ready", {duration: `${diff}ms`, commands: cmds.length, botId: botUser.id, botUsername: botUser.username}); + logger.success("startup.ready", { + duration: `${diff}ms`, + commands: cmds.length, + botId: botUser.id, + botUsername: botUser.username + }); } catch (error) { logError(error); } diff --git a/src/util/utils.ts b/src/util/utils.ts index d5c9e2d..b3354fd 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -2122,7 +2122,12 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis UserStore.put(from) ] ); - messageLogger.debug("message.persisted", {chatId: msg.chat.id, messageId: msg.message_id, fromId: from.id, duration: logger.duration(startedAt)}); + messageLogger.debug("message.persisted", { + chatId: msg.chat.id, + messageId: msg.message_id, + fromId: from.id, + duration: logger.duration(startedAt) + }); storedMsg = results[0]; locale = await resolveInterfaceLocaleForUser(from.id, from.language_code); @@ -2202,7 +2207,11 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis || !!msg.video_note; const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/"); if (executed) { - messageLogger.debug("message.command_executed", {chatId: msg.chat.id, messageId: msg.message_id, command: cmd?.title}); + messageLogger.debug("message.command_executed", { + chatId: msg.chat.id, + messageId: msg.message_id, + command: cmd?.title + }); return; }