This commit is contained in:
seb
2026-10-05 11:14:31 +02:00
parent 9831bea110
commit 25266cfcec
12 changed files with 1991 additions and 58 deletions

191
src/tools/read-file.js Normal file
View 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;
},
});
}