claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
403 lines • 15.5 kB
JavaScript
/**
* GAIA Tool: file_read — ADR-133-PR2 / iter-53b attachment-tools
*
* Reads a file from the local filesystem and returns its contents as a
* UTF-8 string. Performs content-type dispatch based on extension +
* magic bytes:
*
* Supported extraction formats (iter-53b):
* - Plain text, JSON, CSV, XML, HTML, Markdown, JS/TS, Python, shell, YAML
* - XLSX — openpyxl subprocess (cell values + fill colours)
* - PPTX — python-pptx subprocess (per-slide text)
* - PNG / JPEG / GIF / WebP — returns IMAGE_BASE64 marker for vision API
* - MP3 / WAV — OpenAI Whisper (tiny model) subprocess transcript
* - .py source file — returned as UTF-8 text (no execution)
*
* For PDF / DOCX / other binary: descriptive stub (PR-4 deferred).
*
* IMAGE_BASE64 marker format:
* [IMAGE_BASE64:{"mediaType":"image/png","base64":"...","path":"/abs/path"}]
*
* The agent loop in gaia-agent.ts must parse this marker when it appears
* in a tool_result and convert it to an Anthropic vision content block.
*
* Maximum file size: 5 MB. Paths must be absolute.
*
* Refs: ADR-133, #2156, iter-53b
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
// ---------------------------------------------------------------------------
// Content-type detection
// ---------------------------------------------------------------------------
const EXT_TO_TYPE = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.json': 'application/json',
'.csv': 'text/csv',
'.xml': 'application/xml',
'.html': 'text/html',
'.htm': 'text/html',
'.yaml': 'application/yaml',
'.yml': 'application/yaml',
'.js': 'application/javascript',
'.ts': 'application/typescript',
'.py': 'text/x-python',
'.sh': 'application/x-sh',
'.bash': 'application/x-sh',
'.zsh': 'application/x-sh',
// Handled binary (extraction implemented)
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
// Stub binary (deferred)
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.mp4': 'video/mp4',
};
/** Binary types for which we have extraction logic in iter-53b. */
const HANDLED_BINARY_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'audio/mpeg',
'audio/wav',
]);
/** Binary types for which we return a stub (no extraction yet). */
const STUB_BINARY_TYPES = new Set([
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'video/mp4',
]);
function detectContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return EXT_TO_TYPE[ext] ?? 'application/octet-stream';
}
/** Quick magic-byte check for common binary signatures. */
function hasBinaryMagic(buf) {
if (buf.length < 4)
return false;
// PDF: %PDF
if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46)
return true;
// PNG: \x89PNG
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
return true;
// JPEG: \xff\xd8
if (buf[0] === 0xff && buf[1] === 0xd8)
return true;
// GIF: GIF8
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38)
return true;
// ZIP (DOCX/XLSX/PPTX): PK\x03\x04
if (buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04)
return true;
return false;
}
// ---------------------------------------------------------------------------
// Path validation
// ---------------------------------------------------------------------------
function validatePath(filePath) {
if (!filePath || typeof filePath !== 'string') {
throw new Error('file_read: `path` must be a non-empty string.');
}
if (!path.isAbsolute(filePath)) {
throw new Error(`file_read: path must be absolute. Got: "${filePath}". ` +
'Resolve relative GAIA attachment paths against the cache directory before calling file_read.');
}
if (filePath.includes('\0')) {
throw new Error('file_read: path contains null byte — rejected.');
}
}
// ---------------------------------------------------------------------------
// Python subprocess helper
// ---------------------------------------------------------------------------
/**
* Run a Python script passed via stdin, with optional positional args.
* Uses execFileSync with stdin input to avoid shell-escaping issues.
*/
function runPython(script, args, timeoutMs) {
return execFileSync('python3', ['-', ...args], {
input: script,
encoding: 'utf-8',
timeout: timeoutMs,
}).trim();
}
// ---------------------------------------------------------------------------
// Extraction helpers
// ---------------------------------------------------------------------------
/** Map an ARGB/RGB integer or tuple to a human-readable colour name. */
function colorName(r, g, b) {
if (r < 40 && g < 40 && b < 40)
return 'black';
if (r > 215 && g > 215 && b > 215)
return 'white';
if (r > 200 && g < 80 && b < 80)
return 'red';
if (r < 80 && g > 150 && b < 80)
return 'green';
if (r < 80 && g < 80 && b > 200)
return 'blue';
if (r > 200 && g > 200 && b < 80)
return 'yellow';
if (r > 200 && g > 100 && b < 80)
return 'orange';
if (r > 130 && g < 80 && b > 130)
return 'purple';
if (r < 80 && g > 150 && b > 150)
return 'teal';
if (r > 150 && g < 100 && b < 100)
return 'dark-red';
if (r > 150 && g > 150 && b > 150)
return 'light-gray';
if (r < 100 && g < 100 && b < 100)
return 'dark-gray';
return `rgb(${r},${g},${b})`;
}
/**
* Extract XLSX content via openpyxl Python subprocess.
* Returns cell values + fill colours as structured text.
*/
function extractXlsx(filePath) {
const script = [
'import sys, json',
'from openpyxl import load_workbook',
'wb = load_workbook(sys.argv[1])',
'out = []',
'for ws in wb.worksheets:',
" sheet = {'sheet': ws.title, 'cells': []}",
' for row in ws.iter_rows():',
' for cell in row:',
' fill = cell.fill',
' color = None',
" if fill and fill.fill_type == 'solid':",
' fg = fill.fgColor',
" if fg.type == 'rgb' and fg.rgb not in ('00000000', 'FFFFFFFF', '00FFFFFF', 'FF000000', 'FFFFFFFF'):",
' color = fg.rgb',
' # Include cell if it has a value OR a color',
' if cell.value is None and color is None:',
' continue',
" entry = {'coord': cell.coordinate, 'value': str(cell.value) if cell.value is not None else ''}",
' if color:',
" entry['color'] = color",
" sheet['cells'].append(entry)",
' out.append(sheet)',
'print(json.dumps(out))',
].join('\n');
let raw;
try {
raw = runPython(script, [filePath], 30_000);
}
catch (err) {
throw new Error(`XLSX extraction failed: ${err instanceof Error ? err.message : String(err)}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return `[XLSX parse error]\nRaw output:\n${raw.slice(0, 2000)}`;
}
const lines = [`[XLSX: ${path.basename(filePath)} — ${parsed.length} sheet(s)]`];
for (const ws of parsed) {
lines.push(`\n--- Sheet: ${ws.sheet} ---`);
for (const cell of ws.cells) {
const colorPart = cell.color
? (() => {
const hex = cell.color.slice(-6);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return ` [fill:${colorName(r, g, b)}]`;
})()
: '';
lines.push(` ${cell.coord}: ${cell.value}${colorPart}`);
}
}
return lines.join('\n');
}
/**
* Extract PPTX content via python-pptx Python subprocess.
* Returns per-slide text.
*/
function extractPptx(filePath) {
const script = [
'import sys, json',
'from pptx import Presentation',
'prs = Presentation(sys.argv[1])',
'out = []',
'for i, slide in enumerate(prs.slides):',
' texts = []',
' for shape in slide.shapes:',
' if not shape.has_text_frame:',
' continue',
' for para in shape.text_frame.paragraphs:',
" t = ''.join(run.text for run in para.runs).strip()",
' if t:',
' texts.append(t)',
" out.append({'slide': i + 1, 'texts': texts})",
'print(json.dumps(out))',
].join('\n');
let raw;
try {
raw = runPython(script, [filePath], 30_000);
}
catch (err) {
throw new Error(`PPTX extraction failed: ${err instanceof Error ? err.message : String(err)}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return `[PPTX parse error]\nRaw output:\n${raw.slice(0, 2000)}`;
}
const lines = [`[PPTX: ${path.basename(filePath)} — ${parsed.length} slide(s)]`];
for (const s of parsed) {
lines.push(`\n--- Slide ${s.slide} ---`);
for (const t of s.texts)
lines.push(` ${t}`);
}
return lines.join('\n');
}
/**
* Encode image as base64 and return the IMAGE_BASE64 marker.
* The agent loop will convert this to an Anthropic vision content block.
*/
function extractImage(filePath, contentType) {
const buf = fs.readFileSync(filePath);
const b64 = buf.toString('base64');
const marker = JSON.stringify({ mediaType: contentType, base64: b64, path: filePath });
return `[IMAGE_BASE64:${marker}]`;
}
/**
* Transcribe audio via OpenAI Whisper (tiny model) Python subprocess.
*/
function extractAudio(filePath) {
const script = [
'import sys',
'import whisper',
'model = whisper.load_model("tiny")',
'result = model.transcribe(sys.argv[1])',
"print(result['text'].strip())",
].join('\n');
let transcript;
try {
transcript = runPython(script, [filePath], 120_000);
}
catch (err) {
throw new Error(`Audio transcription failed: ${err instanceof Error ? err.message : String(err)}`);
}
return `[Audio transcript: ${path.basename(filePath)}]\n\n${transcript}`;
}
// ---------------------------------------------------------------------------
// GaiaTool implementation
// ---------------------------------------------------------------------------
export class FileReadTool {
name = 'file_read';
definition = {
name: 'file_read',
description: 'Read the contents of a local file and return them as text. ' +
'The path must be absolute. ' +
'For XLSX files: returns cell values and fill colours. ' +
'For PPTX files: returns per-slide text. ' +
'For image files (PNG/JPEG/GIF/WebP): returns an IMAGE_BASE64 marker for vision API use. ' +
'For audio files (MP3/WAV): returns a Whisper transcript. ' +
'For Python source files (.py): returns the source code as text. ' +
`Maximum file size: ${MAX_FILE_BYTES / 1024 / 1024} MB.`,
input_schema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute path to the file to read.',
},
},
required: ['path'],
},
};
async execute(input) {
const filePath = String(input['path'] ?? '').trim();
validatePath(filePath);
let stat;
try {
stat = fs.statSync(filePath);
}
catch (e) {
const err = e;
if (err.code === 'ENOENT') {
throw new Error(`file_read: file not found: ${filePath}`);
}
throw new Error(`file_read: cannot stat "${filePath}": ${String(e)}`);
}
if (!stat.isFile()) {
throw new Error(`file_read: "${filePath}" is not a regular file.`);
}
if (stat.size > MAX_FILE_BYTES) {
throw new Error(`file_read: file too large (${stat.size} bytes > ${MAX_FILE_BYTES} byte limit): ${filePath}`);
}
const contentType = detectContentType(filePath);
// --- HANDLED binary types: extraction implemented ---
if (HANDLED_BINARY_TYPES.has(contentType)) {
if (contentType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
return extractXlsx(filePath);
}
if (contentType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
return extractPptx(filePath);
}
if (contentType.startsWith('image/')) {
return extractImage(filePath, contentType);
}
if (contentType.startsWith('audio/')) {
return extractAudio(filePath);
}
}
// --- STUB binary types: no extraction yet ---
if (STUB_BINARY_TYPES.has(contentType)) {
return (`[Binary file: ${contentType}]\n` +
`Path: ${filePath}\n` +
`Size: ${stat.size} bytes\n` +
`Note: Text extraction for this format is not yet implemented. ` +
`Describe what you expect the file to contain based on context.`);
}
// --- Read full file for magic-byte check ---
const buf = fs.readFileSync(filePath);
if (hasBinaryMagic(buf)) {
return (`[Binary file — magic bytes detected]\n` +
`Path: ${filePath}\n` +
`Size: ${stat.size} bytes\n` +
`Detected content-type: ${contentType}`);
}
// --- Plain text ---
let text;
try {
text = buf.toString('utf-8');
}
catch {
text = buf.toString('latin1');
}
const header = `[File: ${path.basename(filePath)} | type: ${contentType} | size: ${stat.size} bytes]\n\n`;
return header + text;
}
}
// ---------------------------------------------------------------------------
// Convenience factory
// ---------------------------------------------------------------------------
export function createFileReadTool() {
return new FileReadTool();
}
//# sourceMappingURL=file_read.js.map