Files
dashback/src/tools/read-file.js
2026-10-14 00:21:31 +02:00

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;
},
});
}