u
This commit is contained in:
191
src/tools/read-file.js
Normal file
191
src/tools/read-file.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
|
||||
import { prepareVfs } from './vfs.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:
|
||||
'Read a file from the virtual filesystem (VFS). Paths are relative to the VFS root ' +
|
||||
'and use forward slashes, e.g. "labels/sheet1.png" or "image-pipeline.SKILL". ' +
|
||||
'Reading a SKILL file returns its `title` (first line) separately from the markdown `content`. ' +
|
||||
'Large files are windowed with seek/limit (lines, 0-indexed) and the result reports ' +
|
||||
'how many lines remain. Binary files cannot be read — use analyze_image for images.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['filename'],
|
||||
properties: {
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: 'File path relative to the VFS root, using forward slashes (e.g. "image-pipeline.SKILL").',
|
||||
},
|
||||
seek: {
|
||||
type: 'number',
|
||||
description: 'First line to return, 0-indexed. Default 0.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: `Maximum number of lines to return. Default ${DEFAULT_LIMIT}, max ${MAX_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.');
|
||||
}
|
||||
if (!Number.isInteger(seek) || seek < 0) {
|
||||
throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`);
|
||||
}
|
||||
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.`);
|
||||
}
|
||||
|
||||
if (!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 (!fs.statSync(absPath).isFile()) {
|
||||
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
|
||||
}
|
||||
|
||||
const raw = 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.'
|
||||
: '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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user