import fs from 'fs'; import path from 'path'; import { defineTool, ToolError } from 'libllmorbiter'; import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js'; import { getApiRouteSkill } from './routes-skills.js'; import { prepareVfs } from './vfs.js'; import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js'; import { parseDraftPath, requestDraftRead } from '../services/draftManager.js'; const DEFAULT_LIMIT = 400; const MAX_LIMIT = 5000; const MAX_CHARS = 200_000; /** * read_file — reads a file from the virtual filesystem (VFS). * * Two behaviours worth knowing: * - **SKILL files are parsed.** When the path names a `.SKILL` file, the title line is * split off and returned as a separate `title` field alongside the markdown `content`, * so the model gets the title both as structure and inside the content. Line numbering * (`seek`/`limit`) applies to the whole raw file, title included, so the numbers line up * with what a subsequent `patch` call expects. * - **Binary files are refused**, not base64-dumped: a 4 MB AVIF would blow the context * for zero benefit. Use `analyze_image` for images and `search_images` to find them. */ /** * Splits content into lines, dropping a single trailing newline so a file ending in "\n" * does not report one phantom extra line. * @param {string} text * @returns {string[]} */ function splitLines(text) { if (!text) return []; const lines = text.split('\n'); if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); return lines; } /** * Heuristic binary sniff: a NUL byte or a high share of non-printable bytes in the first * kilobyte. Kept deliberately cheap — this runs on every read. * @param {Buffer} buf * @returns {boolean} */ function looksBinary(buf) { const sample = buf.subarray(0, Math.min(buf.length, 1024)); for (const byte of sample) { if (byte === 0) return true; } let suspicious = 0; for (const byte of sample) { const printable = byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte !== 127); if (!printable) suspicious += 1; } return suspicious / sample.length > 0.3; } /** * Creates the read_file tool bound to a company's virtual filesystem. * @param {{ googleAuthId: number }} ctx */ export function createReadFileTool({ googleAuthId }) { return defineTool({ name: 'read_file', description: TOOL_DESCRIPTIONS.read_file, strict: false, parameters: { type: 'object', additionalProperties: false, required: ['filename'], properties: { filename: { type: 'string', description: TOOL_PARAMETER_DESCRIPTIONS.read_file.filename, }, seek: { type: ['number', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.read_file.seek, }, limit: { type: ['number', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.read_file.limit, }, }, }, invoke: async ({ filename, seek = 0, limit = DEFAULT_LIMIT } = {}) => { if (typeof filename !== 'string' || !filename.trim()) { throw new ToolError('"filename" must be a non-empty string.'); } seek = seek != null ? Number(seek) : 0; if (!Number.isInteger(seek) || seek < 0) { throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`); } limit = limit != null ? Number(limit) : DEFAULT_LIMIT; if (!Number.isInteger(limit) || limit < 1) { throw new ToolError(`"limit" must be a positive integer (received ${limit}).`); } const vfsRoot = await prepareVfs({ googleAuthId }); const clean = filename.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); if (!clean) { throw new ToolError('"filename" must be a non-empty string.'); } const absPath = path.resolve(vfsRoot, clean); const rel = path.relative(vfsRoot, absPath); if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`); } const draftInfo = parseDraftPath(clean); if (draftInfo.isFolder) { throw new ToolError('"drafts" is a directory. Use find_files to list its contents.'); } let raw; let virtualSkill = null; if (draftInfo.isDraft) { try { const content = await requestDraftRead({ userId: googleAuthId, name: draftInfo.name, }); const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2); raw = Buffer.from(str, 'utf8'); } catch (err) { throw new ToolError(`Failed to read draft "${draftInfo.name}": ${err.message}`); } } else { virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null; if (!virtualSkill && !fs.existsSync(absPath)) { throw new ToolError( `File not found in virtual filesystem: "${clean}". ` + 'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.' ); } if (!virtualSkill && !fs.statSync(absPath).isFile()) { throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`); } raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath); } if (looksBinary(raw)) { const ext = path.extname(clean).toLowerCase(); throw new ToolError( `"${clean}" is a binary file (${raw.length} bytes${ext ? `, ${ext}` : ''}) and cannot be read as text. ` + (['.png', '.jpg', '.jpeg', '.avif', '.webp', '.gif', '.bmp'].includes(ext) ? 'Use analyze_image({ path: ' + JSON.stringify(clean) + ' }) to have a vision model inspect it.' : (['.xlsx', '.xls', '.xlsm'].includes(ext) ? 'Use javascript_execute with ExcelJS to inspect or modify spreadsheet files.' : 'Fetch it as bytes from javascript_execute with fs.readFileSync if you need its contents.')) ); } const text = raw.toString('utf8'); const lines = splitLines(text); const totalLines = lines.length; if (seek >= totalLines && totalLines > 0) { throw new ToolError( `seek=${seek} is past the end of "${clean}" (${totalLines} lines). ` + `Valid range is 0..${Math.max(0, totalLines - 1)}.` ); } const cappedLimit = Math.min(limit, MAX_LIMIT); const slice = lines.slice(seek, seek + cappedLimit); const remaining = totalLines - (seek + slice.length); let content = slice.join('\n'); let charTruncated = false; if (content.length > MAX_CHARS) { content = content.slice(0, MAX_CHARS); charTruncated = true; } const isSkill = isSkillPath(clean); const result = { filename: clean, size: raw.length, totalLines, seek, returnedLines: slice.length, content, }; if (isSkill) { // Parse from the FULL raw text, not the windowed slice: with a `seek` the // title line is not in `content`, yet the caller still needs the title. const parsed = parseSkill(text); result.type = 'skill'; result.title = parsed.title; result.extension = SKILL_EXT; if (seek === 0 && slice.length > 0) { // Drop the title line from the markdown body so it is not duplicated. const firstLineEnd = text.indexOf('\n'); const body = firstLineEnd === -1 ? '' : text.slice(firstLineEnd + 1); result.markdown = slice.length === totalLines ? body.replace(/^\r?\n/, '').replace(/\s+$/, '') : '(paged — read again without seek for the full body)'; } } if (remaining > 0) { result.truncatedLines = remaining; result.truncationNote = `${remaining} more line(s) not shown. Continue with seek=${seek + slice.length}.`; } if (charTruncated) { result.truncatedChars = true; result.charTruncationNote = `Content was capped at ${MAX_CHARS} characters.`; } return result; }, }); }