213 lines
9.1 KiB
JavaScript
213 lines
9.1 KiB
JavaScript
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;
|
|
},
|
|
});
|
|
} |