src / tools / documentParsers.ts
src / tools / documentParsers.ts
/**
* Document Parsing Tools
* Extracts text from PDF, EPUB, HTML, and images (OCR).
* Adapted from Big RAG plugin with graceful fallback for missing dependencies.
*/
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
// ──────────────────────────────────────────────────────
// PDF Parser
// ──────────────────────────────────────────────────────
import { validatePath } from '../pathSecurity';
function safePath(filePath: string, workspaceRoot: string): string {
return validatePath(workspaceRoot, workspaceRoot, filePath);
}
export async function parse_pdf(file_path: string, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; text?: string; pages?: number; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const stat = await fs.stat(file_path);
if (stat.size > 100 * 1024 * 1024) { // 100MB limit
return { success: false, error: 'File exceeds 100MB size limit' };
}
const content = await fs.readFile(file_path);
// Try pdf-parse first (if available)
try {
const pdfParseModule = await import('pdf-parse') as any;
const pdfParse = pdfParseModule.default || pdfParseModule;
if (typeof pdfParse === 'function') {
const data = await pdfParse(content);
return { success: true, text: data.text, pages: data.numpages };
}
} catch (parseErr) {
// pdf-parse not available or failed - fall back to basic extraction
console.warn('[DocumentParsers] pdf-parse unavailable, using fallback:', parseErr instanceof Error ? parseErr.message : String(parseErr));
}
// Fallback: try to extract text from PDF structure (limited but works without deps)
const text = extractTextFromPdfFallback(content);
if (text && text.length > 0) {
return { success: true, text, pages: estimatePages(text) };
}
// If all else fails, try OCR on first page as image
try {
const ocrResult = await ocr_image(file_path, workspaceRoot);
if (ocrResult.success && ocrResult.text) {
return { success: true, text: ocrResult.text, pages: 1 };
}
} catch {}
return { success: false, error: 'Could not extract text from PDF. Try installing pdf-parse or use OCR mode.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
// Basic PDF text extraction without external deps
function extractTextFromPdfFallback(content: Buffer): string {
const text = content.toString('utf-8', 0, Math.min(100000, content.length));
// Look for stream data between "stream" and "endstream" markers
const streamRegex = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
let matches: string[] = [];
let match;
while ((match = streamRegex.exec(text)) !== null) {
matches.push(match[1]);
}
// Clean up extracted text (remove PDF operators, escape sequences)
return matches
.map(chunk => chunk
.replace(/\\[nrt()<>[\]{}\/%]/g, ' ') // Remove PDF escapes
.replace(/\r\n/g, '\n')
.replace(/[^\x20-\x7E\n]/g, '') // Keep only printable ASCII + newlines
.trim()
)
.filter(Boolean)
.join('\n');
}
function estimatePages(text: string): number {
const lines = text.split('\n').filter(l => l.trim().length > 0);
return Math.max(1, Math.ceil(lines.length / 50)); // Rough estimate: ~50 lines per page
}
// ──────────────────────────────────────────────────────
// EPUB Parser
// ──────────────────────────────────────────────────────
export async function parse_epub(file_path: string, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; title?: string; chapters?: Array<{ title: string; content: string }>; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const stat = await fs.stat(file_path);
if (stat.size > 50 * 1024 * 1024) { // 50MB limit
return { success: false, error: 'File exceeds 50MB size limit' };
}
const content = await fs.readFile(file_path);
// Try epub2 first (if available)
try {
// @ts-ignore - optional dependency
const epubModule = await import('epub2') as any;
const EPub = epubModule.EPub || epubModule.default?.EPub;
if (!EPub) throw new Error('EPUB module not exported');
return new Promise((resolve, reject) => {
const epub = new EPub(file_path);
epub.on('error', (error: Error) => {
console.error('[DocumentParsers] EPUB parsing error:', error.message);
resolve({ success: false, error: `EPUB parse error: ${error.message}` });
});
const stripHtml = (input: string) => input.replace(/<[^>]*>/g, ' ');
epub.load((err?: Error) => {
if (err) {
resolve({ success: false, error: `EPUB load error: ${err.message}` });
return;
}
const manifest = (epub as any).manifest || {};
const chapters: Array<{ title: string; content: string }> = [];
// Get spine order
const spine = (epub as any).spine || [];
for (const chapterId of spine) {
try {
const entry = manifest[chapterId];
if (!entry) continue;
const href = entry.href || entry.path || '';
const mediaType = entry['media-type'] || entry.mediaType || 'text/html';
// Read chapter content
const chapterContent = epub.getChapterSync?.(href);
if (chapterContent) {
chapters.push({
title: stripHtml(chapterContent.title || '').trim() || `Chapter ${chapters.length + 1}`,
content: stripHtml(chapterContent.content || '')
});
}
} catch {}
}
const title = (epub as any).metadata?.title || path.basename(file_path, '.epub');
resolve({
success: true,
title,
chapters: chapters.length > 0 ? chapters : undefined
});
});
});
} catch (parseErr) {
console.warn('[DocumentParsers] epub2 unavailable:', parseErr instanceof Error ? parseErr.message : String(parseErr));
}
// Fallback: try to extract from ZIP structure (EPUB is a ZIP file)
const text = extractTextFromEpubFallback(content);
if (text && text.length > 0) {
return { success: true, title: path.basename(file_path, '.epub'), chapters: [{ title: 'Content', content: text }] };
}
return { success: false, error: 'Could not parse EPUB. Try installing epub2 or use a different format.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function extractTextFromEpubFallback(content: Buffer): string {
// EPUB is ZIP - try to find text files in it
const text = content.toString('utf-8', 0, Math.min(50000, content.length));
// Look for HTML/XML content between tags
const htmlRegex = /<body[^>]*>([\s\S]*?)<\/body>/gi;
let matches: string[] = [];
let match;
while ((match = htmlRegex.exec(text)) !== null) {
matches.push(match[1]);
}
// Strip HTML tags and clean up
return matches
.map(chunk => chunk.replace(/<[^>]*>/g, ' ').trim())
.filter(Boolean)
.join('\n\n');
}
// ──────────────────────────────────────────────────────
// HTML Parser
// ──────────────────────────────────────────────────────
export async function parse_html(file_path: string, options?: { extract_links?: boolean }, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; title?: string; body_text?: string; links?: Array<{ href: string; text: string }>; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const content = await fs.readFile(file_path, 'utf-8');
// Try cheerio first (if available)
try {
// @ts-ignore - optional dependency
const cheerioModule = await import('cheerio') as any;
const cheerio = cheerioModule.default || cheerioModule;
if (!cheerio?.load) throw new Error('Cheerio not exported');
const $ = cheerio.load(content);
$('script, style, noscript').remove();
const title = $('title').text().trim() || path.basename(file_path, '.html') || path.basename(file_path, '.htm');
const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
// Extract links
const links: Array<{ href: string; text: string }> = [];
$('a[href]').each((_i: number, el: any) => {
const href = $(el).attr('href') || '';
const text = $(el).text().trim();
if (href && text) {
links.push({ href, text });
}
});
return { success: true, title, body_text: bodyText, links: links.length > 0 ? links : undefined };
} catch (parseErr) {
console.warn('[DocumentParsers] cheerio unavailable:', parseErr instanceof Error ? parseErr.message : String(parseErr));
}
// Fallback: basic regex-based extraction
const titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const bodyMatch = content.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return {
success: true,
title: titleMatch?.[1]?.trim() || path.basename(file_path),
body_text: bodyMatch ? stripHtml(bodyMatch[1]) : '',
links: extractLinksFallback(content)
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
function extractLinksFallback(content: string): Array<{ href: string; text: string }> {
const links: Array<{ href: string; text: string }> = [];
const linkRegex = /<a\s+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
let match;
while ((match = linkRegex.exec(content)) !== null) {
links.push({ href: match[1], text: stripHtml(match[2]) });
}
return links;
}
// ──────────────────────────────────────────────────────
// Image OCR Parser
// ──────────────────────────────────────────────────────
export async function ocr_image(file_path: string, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; text?: string; confidence?: number; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const stat = await fs.stat(file_path);
if (stat.size > 20 * 1024 * 1024) { // 20MB limit
return { success: false, error: 'Image exceeds 20MB size limit' };
}
// Try tesseract.js first (if available and enabled)
try {
// @ts-ignore - optional dependency
const tesseractModule = await import('tesseract.js') as any;
const createWorker = tesseractModule.createWorker || tesseractModule.default?.createWorker;
if (!createWorker) throw new Error('Tesseract not exported');
const worker = await createWorker('eng');
const { data: { text, confidence } } = await worker.recognize(file_path);
await worker.terminate();
return {
success: true,
text: text.replace(/\s+/g, ' ').trim(),
confidence
};
} catch (ocrErr) {
console.warn('[DocumentParsers] OCR unavailable:', ocrErr instanceof Error ? ocrErr.message : String(ocrErr));
}
return { success: false, error: 'OCR requires Tesseract.js and Tesseract OCR engine installed. Enable in plugin settings.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
// ──────────────────────────────────────────────────────
// Zod schemas for tool parameters
// ──────────────────────────────────────────────────────
export const parsePdfSchema = z.object({
file_path: z.string().min(1).describe('Path to the PDF file'),
});
export const parseEpubSchema = z.object({
file_path: z.string().min(1).describe('Path to the EPUB file'),
});
export const parseHtmlSchema = z.object({
file_path: z.string().min(1).describe('Path to the HTML file'),
options: z.object({
extract_links: z.boolean().optional().default(true),
}).optional(),
});
export const ocrImageSchema = z.object({
file_path: z.string().min(1).describe('Path to the image file (PNG, JPG, BMP)'),
});
// ──────────────────────────────────────────────────────
// Text Chunker for RAG
// ──────────────────────────────────────────────────────
export async function chunk_text(text: string, options?: {
chunk_size?: number;
overlap?: number;
}): Promise<{ success: boolean; chunks: Array<{ index: number; content: string }>; total_chunks: number; error?: string }> {
try {
const chunkSize = options?.chunk_size ?? 500; // chars per chunk
const overlap = options?.overlap ?? 50; // chars of overlap between chunks
if (!text || text.length === 0) {
return { success: false, chunks: [], total_chunks: 0, error: 'Input text is empty' };
}
const chunks: Array<{ index: number; content: string }> = [];
let start = 0;
let index = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
let chunkEnd = end;
// Try to break at sentence boundary if possible
if (end < text.length) {
const lastPeriod = text.lastIndexOf('.', end - 1);
const lastNewline = text.lastIndexOf('\n', end - 1);
const lastSpace = text.lastIndexOf(' ', end - 1);
// Prefer sentence boundary, then newline, then space
let breakPoint = Math.max(lastPeriod, lastNewline, lastSpace);
if (breakPoint > start + chunkSize * 0.5) {
chunkEnd = breakPoint + 1; // Include the period/newline/space
} else {
chunkEnd = end;
}
}
const chunkContent = text.slice(start, chunkEnd).trim();
if (chunkContent.length > 0) {
chunks.push({ index: index++, content: chunkContent });
}
start = chunkEnd - overlap;
if (start <= 0) start = 0; // Prevent infinite loop on small text
}
return { success: true, chunks, total_chunks: chunks.length };
} catch (error) {
return { success: false, chunks: [], total_chunks: 0, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* Document Parsing Tools
* Extracts text from PDF, EPUB, HTML, and images (OCR).
* Adapted from Big RAG plugin with graceful fallback for missing dependencies.
*/
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
// ──────────────────────────────────────────────────────
// PDF Parser
// ──────────────────────────────────────────────────────
import { validatePath } from '../pathSecurity';
function safePath(filePath: string, workspaceRoot: string): string {
return validatePath(workspaceRoot, workspaceRoot, filePath);
}
export async function parse_pdf(file_path: string, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; text?: string; pages?: number; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const stat = await fs.stat(file_path);
if (stat.size > 100 * 1024 * 1024) { // 100MB limit
return { success: false, error: 'File exceeds 100MB size limit' };
}
const content = await fs.readFile(file_path);
// Try pdf-parse first (if available)
try {
const pdfParseModule = await import('pdf-parse') as any;
const pdfParse = pdfParseModule.default || pdfParseModule;
if (typeof pdfParse === 'function') {
const data = await pdfParse(content);
return { success: true, text: data.text, pages: data.numpages };
}
} catch (parseErr) {
// pdf-parse not available or failed - fall back to basic extraction
console.warn('[DocumentParsers] pdf-parse unavailable, using fallback:', parseErr instanceof Error ? parseErr.message : String(parseErr));
}
// Fallback: try to extract text from PDF structure (limited but works without deps)
const text = extractTextFromPdfFallback(content);
if (text && text.length > 0) {
return { success: true, text, pages: estimatePages(text) };
}
// If all else fails, try OCR on first page as image
try {
const ocrResult = await ocr_image(file_path, workspaceRoot);
if (ocrResult.success && ocrResult.text) {
return { success: true, text: ocrResult.text, pages: 1 };
}
} catch {}
return { success: false, error: 'Could not extract text from PDF. Try installing pdf-parse or use OCR mode.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
// Basic PDF text extraction without external deps
function extractTextFromPdfFallback(content: Buffer): string {
const text = content.toString('utf-8', 0, Math.min(100000, content.length));
// Look for stream data between "stream" and "endstream" markers
const streamRegex = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
let matches: string[] = [];
let match;
while ((match = streamRegex.exec(text)) !== null) {
matches.push(match[1]);
}
// Clean up extracted text (remove PDF operators, escape sequences)
return matches
.map(chunk => chunk
.replace(/\\[nrt()<>[\]{}\/%]/g, ' ') // Remove PDF escapes
.replace(/\r\n/g, '\n')
.replace(/[^\x20-\x7E\n]/g, '') // Keep only printable ASCII + newlines
.trim()
)
.filter(Boolean)
.join('\n');
}
function estimatePages(text: string): number {
const lines = text.split('\n').filter(l => l.trim().length > 0);
return Math.max(1, Math.ceil(lines.length / 50)); // Rough estimate: ~50 lines per page
}
// ──────────────────────────────────────────────────────
// EPUB Parser
// ──────────────────────────────────────────────────────
export async function parse_epub(file_path: string, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; title?: string; chapters?: Array<{ title: string; content: string }>; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const stat = await fs.stat(file_path);
if (stat.size > 50 * 1024 * 1024) { // 50MB limit
return { success: false, error: 'File exceeds 50MB size limit' };
}
const content = await fs.readFile(file_path);
// Try epub2 first (if available)
try {
// @ts-ignore - optional dependency
const epubModule = await import('epub2') as any;
const EPub = epubModule.EPub || epubModule.default?.EPub;
if (!EPub) throw new Error('EPUB module not exported');
return new Promise((resolve, reject) => {
const epub = new EPub(file_path);
epub.on('error', (error: Error) => {
console.error('[DocumentParsers] EPUB parsing error:', error.message);
resolve({ success: false, error: `EPUB parse error: ${error.message}` });
});
const stripHtml = (input: string) => input.replace(/<[^>]*>/g, ' ');
epub.load((err?: Error) => {
if (err) {
resolve({ success: false, error: `EPUB load error: ${err.message}` });
return;
}
const manifest = (epub as any).manifest || {};
const chapters: Array<{ title: string; content: string }> = [];
// Get spine order
const spine = (epub as any).spine || [];
for (const chapterId of spine) {
try {
const entry = manifest[chapterId];
if (!entry) continue;
const href = entry.href || entry.path || '';
const mediaType = entry['media-type'] || entry.mediaType || 'text/html';
// Read chapter content
const chapterContent = epub.getChapterSync?.(href);
if (chapterContent) {
chapters.push({
title: stripHtml(chapterContent.title || '').trim() || `Chapter ${chapters.length + 1}`,
content: stripHtml(chapterContent.content || '')
});
}
} catch {}
}
const title = (epub as any).metadata?.title || path.basename(file_path, '.epub');
resolve({
success: true,
title,
chapters: chapters.length > 0 ? chapters : undefined
});
});
});
} catch (parseErr) {
console.warn('[DocumentParsers] epub2 unavailable:', parseErr instanceof Error ? parseErr.message : String(parseErr));
}
// Fallback: try to extract from ZIP structure (EPUB is a ZIP file)
const text = extractTextFromEpubFallback(content);
if (text && text.length > 0) {
return { success: true, title: path.basename(file_path, '.epub'), chapters: [{ title: 'Content', content: text }] };
}
return { success: false, error: 'Could not parse EPUB. Try installing epub2 or use a different format.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function extractTextFromEpubFallback(content: Buffer): string {
// EPUB is ZIP - try to find text files in it
const text = content.toString('utf-8', 0, Math.min(50000, content.length));
// Look for HTML/XML content between tags
const htmlRegex = /<body[^>]*>([\s\S]*?)<\/body>/gi;
let matches: string[] = [];
let match;
while ((match = htmlRegex.exec(text)) !== null) {
matches.push(match[1]);
}
// Strip HTML tags and clean up
return matches
.map(chunk => chunk.replace(/<[^>]*>/g, ' ').trim())
.filter(Boolean)
.join('\n\n');
}
// ──────────────────────────────────────────────────────
// HTML Parser
// ──────────────────────────────────────────────────────
export async function parse_html(file_path: string, options?: { extract_links?: boolean }, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; title?: string; body_text?: string; links?: Array<{ href: string; text: string }>; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const content = await fs.readFile(file_path, 'utf-8');
// Try cheerio first (if available)
try {
// @ts-ignore - optional dependency
const cheerioModule = await import('cheerio') as any;
const cheerio = cheerioModule.default || cheerioModule;
if (!cheerio?.load) throw new Error('Cheerio not exported');
const $ = cheerio.load(content);
$('script, style, noscript').remove();
const title = $('title').text().trim() || path.basename(file_path, '.html') || path.basename(file_path, '.htm');
const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
// Extract links
const links: Array<{ href: string; text: string }> = [];
$('a[href]').each((_i: number, el: any) => {
const href = $(el).attr('href') || '';
const text = $(el).text().trim();
if (href && text) {
links.push({ href, text });
}
});
return { success: true, title, body_text: bodyText, links: links.length > 0 ? links : undefined };
} catch (parseErr) {
console.warn('[DocumentParsers] cheerio unavailable:', parseErr instanceof Error ? parseErr.message : String(parseErr));
}
// Fallback: basic regex-based extraction
const titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const bodyMatch = content.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return {
success: true,
title: titleMatch?.[1]?.trim() || path.basename(file_path),
body_text: bodyMatch ? stripHtml(bodyMatch[1]) : '',
links: extractLinksFallback(content)
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
function extractLinksFallback(content: string): Array<{ href: string; text: string }> {
const links: Array<{ href: string; text: string }> = [];
const linkRegex = /<a\s+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
let match;
while ((match = linkRegex.exec(content)) !== null) {
links.push({ href: match[1], text: stripHtml(match[2]) });
}
return links;
}
// ──────────────────────────────────────────────────────
// Image OCR Parser
// ──────────────────────────────────────────────────────
export async function ocr_image(file_path: string, workspaceRoot: string = process.cwd()): Promise<{ success: boolean; text?: string; confidence?: number; error?: string }> {
file_path = safePath(file_path, workspaceRoot);
try {
const stat = await fs.stat(file_path);
if (stat.size > 20 * 1024 * 1024) { // 20MB limit
return { success: false, error: 'Image exceeds 20MB size limit' };
}
// Try tesseract.js first (if available and enabled)
try {
// @ts-ignore - optional dependency
const tesseractModule = await import('tesseract.js') as any;
const createWorker = tesseractModule.createWorker || tesseractModule.default?.createWorker;
if (!createWorker) throw new Error('Tesseract not exported');
const worker = await createWorker('eng');
const { data: { text, confidence } } = await worker.recognize(file_path);
await worker.terminate();
return {
success: true,
text: text.replace(/\s+/g, ' ').trim(),
confidence
};
} catch (ocrErr) {
console.warn('[DocumentParsers] OCR unavailable:', ocrErr instanceof Error ? ocrErr.message : String(ocrErr));
}
return { success: false, error: 'OCR requires Tesseract.js and Tesseract OCR engine installed. Enable in plugin settings.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
// ──────────────────────────────────────────────────────
// Zod schemas for tool parameters
// ──────────────────────────────────────────────────────
export const parsePdfSchema = z.object({
file_path: z.string().min(1).describe('Path to the PDF file'),
});
export const parseEpubSchema = z.object({
file_path: z.string().min(1).describe('Path to the EPUB file'),
});
export const parseHtmlSchema = z.object({
file_path: z.string().min(1).describe('Path to the HTML file'),
options: z.object({
extract_links: z.boolean().optional().default(true),
}).optional(),
});
export const ocrImageSchema = z.object({
file_path: z.string().min(1).describe('Path to the image file (PNG, JPG, BMP)'),
});
// ──────────────────────────────────────────────────────
// Text Chunker for RAG
// ──────────────────────────────────────────────────────
export async function chunk_text(text: string, options?: {
chunk_size?: number;
overlap?: number;
}): Promise<{ success: boolean; chunks: Array<{ index: number; content: string }>; total_chunks: number; error?: string }> {
try {
const chunkSize = options?.chunk_size ?? 500; // chars per chunk
const overlap = options?.overlap ?? 50; // chars of overlap between chunks
if (!text || text.length === 0) {
return { success: false, chunks: [], total_chunks: 0, error: 'Input text is empty' };
}
const chunks: Array<{ index: number; content: string }> = [];
let start = 0;
let index = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
let chunkEnd = end;
// Try to break at sentence boundary if possible
if (end < text.length) {
const lastPeriod = text.lastIndexOf('.', end - 1);
const lastNewline = text.lastIndexOf('\n', end - 1);
const lastSpace = text.lastIndexOf(' ', end - 1);
// Prefer sentence boundary, then newline, then space
let breakPoint = Math.max(lastPeriod, lastNewline, lastSpace);
if (breakPoint > start + chunkSize * 0.5) {
chunkEnd = breakPoint + 1; // Include the period/newline/space
} else {
chunkEnd = end;
}
}
const chunkContent = text.slice(start, chunkEnd).trim();
if (chunkContent.length > 0) {
chunks.push({ index: index++, content: chunkContent });
}
start = chunkEnd - overlap;
if (start <= 0) start = 0; // Prevent infinite loop on small text
}
return { success: true, chunks, total_chunks: chunks.length };
} catch (error) {
return { success: false, chunks: [], total_chunks: 0, error: error instanceof Error ? error.message : String(error) };
}
}