doc2vec 1.3.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -8
- package/dist/code-chunker.js +179 -0
- package/dist/content-processor copy.js +819 -0
- package/dist/content-processor.js +620 -72
- package/dist/database.js +205 -19
- package/dist/doc2vec.js +520 -8
- package/dist/electron-app/src/database.js +218 -0
- package/dist/electron-app/src/embeddings.js +80 -0
- package/dist/electron-app/src/main.js +627 -0
- package/dist/electron-app/src/mcp-server.js +496 -0
- package/dist/electron-app/src/preload.js +24 -0
- package/dist/electron-app/src/processor.js +248 -0
- package/dist/electron-app/src/syncer.js +146 -0
- package/dist/macos-app/migrate-to-vec0.js +156 -0
- package/dist/vitest.config.js +16 -0
- package/package.json +13 -3
- package/dist/embedding-providers.js +0 -157
|
@@ -47,6 +47,7 @@ const turndown_1 = __importDefault(require("turndown"));
|
|
|
47
47
|
const fs = __importStar(require("fs"));
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
49
|
const utils_1 = require("./utils");
|
|
50
|
+
const code_chunker_1 = require("./code-chunker");
|
|
50
51
|
class ContentProcessor {
|
|
51
52
|
constructor(logger) {
|
|
52
53
|
this.logger = logger;
|
|
@@ -54,6 +55,9 @@ class ContentProcessor {
|
|
|
54
55
|
codeBlockStyle: 'fenced',
|
|
55
56
|
headingStyle: 'atx'
|
|
56
57
|
});
|
|
58
|
+
this.tokenChunkerCache = new Map();
|
|
59
|
+
this.codeChunkerCache = new Map();
|
|
60
|
+
this.tokenizerCache = null;
|
|
57
61
|
this.setupTurndownRules();
|
|
58
62
|
}
|
|
59
63
|
setupTurndownRules() {
|
|
@@ -200,15 +204,38 @@ class ContentProcessor {
|
|
|
200
204
|
async crawlWebsite(baseUrl, sourceConfig, processPageContent, parentLogger, visitedUrls) {
|
|
201
205
|
const logger = parentLogger.child('crawler');
|
|
202
206
|
const queue = [baseUrl];
|
|
207
|
+
const brokenLinks = [];
|
|
208
|
+
const brokenLinkKeys = new Set();
|
|
209
|
+
const referrers = new Map();
|
|
210
|
+
const addReferrer = (targetUrl, sourceUrl) => {
|
|
211
|
+
const existing = referrers.get(targetUrl);
|
|
212
|
+
if (existing) {
|
|
213
|
+
existing.add(sourceUrl);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
referrers.set(targetUrl, new Set([sourceUrl]));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const addBrokenLink = (sourceUrl, targetUrl) => {
|
|
220
|
+
const key = `${sourceUrl} -> ${targetUrl}`;
|
|
221
|
+
if (brokenLinkKeys.has(key))
|
|
222
|
+
return;
|
|
223
|
+
brokenLinkKeys.add(key);
|
|
224
|
+
brokenLinks.push({ source: sourceUrl, target: targetUrl });
|
|
225
|
+
};
|
|
226
|
+
addReferrer(baseUrl, baseUrl);
|
|
203
227
|
// Process sitemap if provided
|
|
204
228
|
if (sourceConfig.sitemap_url) {
|
|
205
229
|
logger.section('SITEMAP PROCESSING');
|
|
206
230
|
const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
|
|
207
231
|
// Add sitemap URLs to the queue if they're within the website scope
|
|
208
232
|
for (const url of sitemapUrls) {
|
|
209
|
-
if (url.startsWith(sourceConfig.url)
|
|
210
|
-
|
|
211
|
-
queue.
|
|
233
|
+
if (url.startsWith(sourceConfig.url)) {
|
|
234
|
+
addReferrer(url, sourceConfig.sitemap_url);
|
|
235
|
+
if (!queue.includes(url)) {
|
|
236
|
+
logger.debug(`Adding URL from sitemap to queue: ${url}`);
|
|
237
|
+
queue.push(url);
|
|
238
|
+
}
|
|
212
239
|
}
|
|
213
240
|
}
|
|
214
241
|
logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
|
|
@@ -235,7 +262,14 @@ class ContentProcessor {
|
|
|
235
262
|
}
|
|
236
263
|
try {
|
|
237
264
|
logger.info(`Crawling: ${url}`);
|
|
238
|
-
const
|
|
265
|
+
const sources = referrers.get(url) ?? new Set([baseUrl]);
|
|
266
|
+
const content = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
|
|
267
|
+
if (status === 404) {
|
|
268
|
+
for (const source of sources) {
|
|
269
|
+
addBrokenLink(source, reportedUrl);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
239
273
|
if (content !== null) {
|
|
240
274
|
await processPageContent(url, content);
|
|
241
275
|
if (utils_1.Utils.isPdfUrl(url)) {
|
|
@@ -252,17 +286,21 @@ class ContentProcessor {
|
|
|
252
286
|
if (!utils_1.Utils.isPdfUrl(url)) {
|
|
253
287
|
const response = await axios_1.default.get(url);
|
|
254
288
|
const $ = (0, cheerio_1.load)(response.data);
|
|
289
|
+
const pageUrlForLinks = response?.request?.res?.responseUrl || url;
|
|
255
290
|
logger.debug(`Finding links on page ${url}`);
|
|
256
291
|
let newLinksFound = 0;
|
|
257
292
|
$('a[href]').each((_, element) => {
|
|
258
293
|
const href = $(element).attr('href');
|
|
259
294
|
if (!href || href.startsWith('#') || href.startsWith('mailto:'))
|
|
260
295
|
return;
|
|
261
|
-
const fullUrl = utils_1.Utils.buildUrl(href,
|
|
262
|
-
if (fullUrl.startsWith(sourceConfig.url)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
296
|
+
const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
|
|
297
|
+
if (fullUrl.startsWith(sourceConfig.url)) {
|
|
298
|
+
addReferrer(fullUrl, pageUrlForLinks);
|
|
299
|
+
if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
|
|
300
|
+
if (!queue.includes(fullUrl)) {
|
|
301
|
+
queue.push(fullUrl);
|
|
302
|
+
newLinksFound++;
|
|
303
|
+
}
|
|
266
304
|
}
|
|
267
305
|
}
|
|
268
306
|
});
|
|
@@ -272,6 +310,13 @@ class ContentProcessor {
|
|
|
272
310
|
catch (error) {
|
|
273
311
|
logger.error(`Failed during processing or link discovery for ${url}:`, error);
|
|
274
312
|
errorCount++;
|
|
313
|
+
const status = this.getHttpStatus(error);
|
|
314
|
+
if (status === 404) {
|
|
315
|
+
const sources = referrers.get(url) ?? new Set([baseUrl]);
|
|
316
|
+
for (const source of sources) {
|
|
317
|
+
addBrokenLink(source, url);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
275
320
|
// Check if this is a network error (DNS resolution, connection issues, etc.)
|
|
276
321
|
if (this.isNetworkError(error)) {
|
|
277
322
|
hasNetworkErrors = true;
|
|
@@ -283,7 +328,7 @@ class ContentProcessor {
|
|
|
283
328
|
if (hasNetworkErrors) {
|
|
284
329
|
logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
|
|
285
330
|
}
|
|
286
|
-
return { hasNetworkErrors };
|
|
331
|
+
return { hasNetworkErrors, brokenLinks };
|
|
287
332
|
}
|
|
288
333
|
isNetworkError(error) {
|
|
289
334
|
// Check for common network error patterns
|
|
@@ -324,7 +369,7 @@ class ContentProcessor {
|
|
|
324
369
|
}
|
|
325
370
|
return false;
|
|
326
371
|
}
|
|
327
|
-
async processPage(url, sourceConfig) {
|
|
372
|
+
async processPage(url, sourceConfig, onHttpStatus) {
|
|
328
373
|
const logger = this.logger.child('page-processor');
|
|
329
374
|
logger.debug(`Processing content from ${url}`);
|
|
330
375
|
// Check if this is a PDF URL
|
|
@@ -340,6 +385,13 @@ class ContentProcessor {
|
|
|
340
385
|
return markdown;
|
|
341
386
|
}
|
|
342
387
|
catch (error) {
|
|
388
|
+
const status = this.getHttpStatus(error);
|
|
389
|
+
if (status !== undefined && status >= 400) {
|
|
390
|
+
if (onHttpStatus) {
|
|
391
|
+
onHttpStatus(url, status);
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
343
395
|
logger.error(`Failed to process PDF ${url}:`, error);
|
|
344
396
|
return null;
|
|
345
397
|
}
|
|
@@ -347,14 +399,38 @@ class ContentProcessor {
|
|
|
347
399
|
// Original HTML page processing logic
|
|
348
400
|
let browser = null;
|
|
349
401
|
try {
|
|
402
|
+
// Use system Chromium if available (for Docker environments)
|
|
403
|
+
let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
404
|
+
if (!executablePath) {
|
|
405
|
+
if (fs.existsSync('/usr/bin/chromium')) {
|
|
406
|
+
executablePath = '/usr/bin/chromium';
|
|
407
|
+
}
|
|
408
|
+
else if (fs.existsSync('/usr/bin/chromium-browser')) {
|
|
409
|
+
executablePath = '/usr/bin/chromium-browser';
|
|
410
|
+
}
|
|
411
|
+
}
|
|
350
412
|
browser = await puppeteer_1.default.launch({
|
|
413
|
+
executablePath,
|
|
351
414
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
352
415
|
});
|
|
353
416
|
const page = await browser.newPage();
|
|
354
417
|
logger.debug(`Navigating to ${url}`);
|
|
355
|
-
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
418
|
+
const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
419
|
+
const status = response?.status();
|
|
420
|
+
if (status !== undefined && status >= 400) {
|
|
421
|
+
const error = new Error(`Failed to load page: HTTP ${status}`);
|
|
422
|
+
error.status = status;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
356
425
|
const htmlContent = await page.evaluate(() => {
|
|
357
|
-
|
|
426
|
+
// 💡 Try specific content selectors first, then fall back to broader ones
|
|
427
|
+
const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
|
|
428
|
+
document.querySelector('.doc-content') || // Alternative docs pattern
|
|
429
|
+
document.querySelector('.markdown-body') || // GitHub-style
|
|
430
|
+
document.querySelector('article') || // Semantic article
|
|
431
|
+
document.querySelector('div[role="main"].document') ||
|
|
432
|
+
document.querySelector('main') ||
|
|
433
|
+
document.body;
|
|
358
434
|
return mainContentElement.innerHTML;
|
|
359
435
|
});
|
|
360
436
|
if (htmlContent.length > sourceConfig.max_size) {
|
|
@@ -370,10 +446,24 @@ class ContentProcessor {
|
|
|
370
446
|
pre.setAttribute('data-readable-content-score', '100');
|
|
371
447
|
this.markCodeParents(pre.parentElement);
|
|
372
448
|
});
|
|
449
|
+
// 💡 Extract H1s BEFORE Readability - it often strips them as "chrome"
|
|
450
|
+
// We'll inject them back after Readability processing
|
|
451
|
+
const h1Elements = document.querySelectorAll('h1');
|
|
452
|
+
const extractedH1s = [];
|
|
453
|
+
logger.debug(`[Readability Debug] Found ${h1Elements.length} H1 elements before Readability`);
|
|
454
|
+
h1Elements.forEach((h1, index) => {
|
|
455
|
+
const h1Text = h1.textContent?.trim() || '';
|
|
456
|
+
// Skip empty H1s or icon-only H1s (like "link" anchors)
|
|
457
|
+
if (h1Text && h1Text.length > 3 && !h1Text.match(/^(link|#|menu|close)$/i)) {
|
|
458
|
+
extractedH1s.push(h1Text);
|
|
459
|
+
logger.debug(`[Readability Debug] Extracted H1[${index}]: "${h1Text.substring(0, 50)}..."`);
|
|
460
|
+
}
|
|
461
|
+
h1.classList.add('original-h1');
|
|
462
|
+
});
|
|
373
463
|
logger.debug(`Applying Readability to extract main content`);
|
|
374
464
|
const reader = new readability_1.Readability(document, {
|
|
375
465
|
charThreshold: 20,
|
|
376
|
-
classesToPreserve: ['article-content'],
|
|
466
|
+
classesToPreserve: ['article-content', 'original-h1'],
|
|
377
467
|
});
|
|
378
468
|
const article = reader.parse();
|
|
379
469
|
if (!article) {
|
|
@@ -381,8 +471,35 @@ class ContentProcessor {
|
|
|
381
471
|
await browser.close();
|
|
382
472
|
return null;
|
|
383
473
|
}
|
|
384
|
-
|
|
385
|
-
|
|
474
|
+
// Debug: Log what Readability extracted
|
|
475
|
+
logger.debug(`[Readability Debug] article.title: "${article.title}"`);
|
|
476
|
+
logger.debug(`[Readability Debug] article.content length: ${article.content?.length}`);
|
|
477
|
+
logger.debug(`[Readability Debug] article.content starts with: "${article.content?.substring(0, 200)}..."`);
|
|
478
|
+
logger.debug(`[Readability Debug] Contains H1 tag: ${article.content?.includes('<h1')}`);
|
|
479
|
+
logger.debug(`[Readability Debug] Contains H2 tag: ${article.content?.includes('<h2')}`);
|
|
480
|
+
logger.debug(`[Readability Debug] Contains original-h1 class: ${article.content?.includes('original-h1')}`);
|
|
481
|
+
// 💡 Restore H1s: find elements with our marker class and convert back from H2
|
|
482
|
+
const articleDom = new jsdom_1.JSDOM(article.content);
|
|
483
|
+
const articleDoc = articleDom.window.document;
|
|
484
|
+
const originalH1Elements = articleDoc.querySelectorAll('.original-h1');
|
|
485
|
+
logger.debug(`[Readability Debug] Found ${originalH1Elements.length} elements with .original-h1 class to restore`);
|
|
486
|
+
originalH1Elements.forEach((heading, index) => {
|
|
487
|
+
logger.debug(`[Readability Debug] Restoring[${index}]: tagName=${heading.tagName}, text="${heading.textContent?.trim().substring(0, 50)}..."`);
|
|
488
|
+
// Create a new H1 element with the same content
|
|
489
|
+
const h1 = articleDoc.createElement('h1');
|
|
490
|
+
h1.innerHTML = heading.innerHTML;
|
|
491
|
+
// Copy other attributes except class
|
|
492
|
+
Array.from(heading.attributes).forEach(attr => {
|
|
493
|
+
if (attr.name !== 'class') {
|
|
494
|
+
h1.setAttribute(attr.name, attr.value);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
heading.replaceWith(h1);
|
|
498
|
+
});
|
|
499
|
+
const restoredContent = articleDoc.body.innerHTML;
|
|
500
|
+
logger.debug(`[Readability Debug] Restored content contains H1: ${restoredContent.includes('<h1')}`);
|
|
501
|
+
logger.debug(`Sanitizing HTML (${restoredContent.length} chars)`);
|
|
502
|
+
const cleanHtml = (0, sanitize_html_1.default)(restoredContent, {
|
|
386
503
|
allowedTags: [
|
|
387
504
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
388
505
|
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
@@ -397,11 +514,37 @@ class ContentProcessor {
|
|
|
397
514
|
}
|
|
398
515
|
});
|
|
399
516
|
logger.debug(`Converting HTML to Markdown`);
|
|
400
|
-
|
|
517
|
+
let markdown = this.turndownService.turndown(cleanHtml);
|
|
518
|
+
// 💡 Inject extracted H1s back if they're not in the markdown
|
|
519
|
+
// Readability often strips them as "page chrome"
|
|
520
|
+
// Use article.title as fallback if no H1 was extracted
|
|
521
|
+
const pageTitle = extractedH1s.length > 0 ? extractedH1s[0] : (article.title?.trim() || '');
|
|
522
|
+
if (pageTitle) {
|
|
523
|
+
// Check if markdown already starts with this exact H1 (allowing for leading whitespace)
|
|
524
|
+
const normalizedTitle = pageTitle.replace(/\s+/g, ' ');
|
|
525
|
+
const markdownFirstLine = markdown.trimStart().split('\n')[0] || '';
|
|
526
|
+
const existingH1Match = markdownFirstLine.match(/^#\s+(.+)$/);
|
|
527
|
+
const existingH1Text = existingH1Match ? existingH1Match[1].replace(/\s+/g, ' ').trim() : '';
|
|
528
|
+
// Only inject if markdown doesn't already start with this H1
|
|
529
|
+
if (!existingH1Match || existingH1Text !== normalizedTitle) {
|
|
530
|
+
markdown = `# ${pageTitle}\n\n${markdown}`;
|
|
531
|
+
logger.debug(`[Readability Debug] Injected page title as H1: "${pageTitle}"`);
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
logger.debug(`[Readability Debug] H1 "${pageTitle}" already present in markdown`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
401
537
|
logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
|
|
402
538
|
return markdown;
|
|
403
539
|
}
|
|
404
540
|
catch (error) {
|
|
541
|
+
const status = this.getHttpStatus(error);
|
|
542
|
+
if (status !== undefined && status >= 400) {
|
|
543
|
+
if (onHttpStatus) {
|
|
544
|
+
onHttpStatus(url, status);
|
|
545
|
+
}
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
405
548
|
logger.error(`Error processing page ${url}:`, error);
|
|
406
549
|
return null;
|
|
407
550
|
}
|
|
@@ -412,6 +555,15 @@ class ContentProcessor {
|
|
|
412
555
|
}
|
|
413
556
|
}
|
|
414
557
|
}
|
|
558
|
+
getHttpStatus(error) {
|
|
559
|
+
if (typeof error?.status === 'number') {
|
|
560
|
+
return error.status;
|
|
561
|
+
}
|
|
562
|
+
if (typeof error?.response?.status === 'number') {
|
|
563
|
+
return error.response.status;
|
|
564
|
+
}
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
415
567
|
markCodeParents(node) {
|
|
416
568
|
if (!node)
|
|
417
569
|
return;
|
|
@@ -421,6 +573,68 @@ class ContentProcessor {
|
|
|
421
573
|
}
|
|
422
574
|
this.markCodeParents(node.parentElement);
|
|
423
575
|
}
|
|
576
|
+
async convertDocToMarkdown(filePath, logger) {
|
|
577
|
+
logger.debug(`Converting DOC to markdown: ${filePath}`);
|
|
578
|
+
try {
|
|
579
|
+
// Dynamic import for word-extractor
|
|
580
|
+
const WordExtractor = (await Promise.resolve().then(() => __importStar(require('word-extractor')))).default;
|
|
581
|
+
const extractor = new WordExtractor();
|
|
582
|
+
const extracted = await extractor.extract(filePath);
|
|
583
|
+
const text = extracted.getBody();
|
|
584
|
+
// Create markdown with filename as title
|
|
585
|
+
let markdown = `# ${path.basename(filePath, '.doc')}\n\n`;
|
|
586
|
+
// Clean up the text and add to markdown
|
|
587
|
+
const cleanedText = text
|
|
588
|
+
.replace(/\r\n/g, '\n') // Normalize line endings
|
|
589
|
+
.replace(/\n{3,}/g, '\n\n') // Remove excessive line breaks
|
|
590
|
+
.trim();
|
|
591
|
+
markdown += cleanedText;
|
|
592
|
+
logger.debug(`Converted DOC to ${markdown.length} characters of markdown`);
|
|
593
|
+
return markdown;
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
logger.error(`Failed to convert DOC ${filePath}:`, error);
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async convertDocxToMarkdown(filePath, logger) {
|
|
601
|
+
logger.debug(`Converting DOCX to markdown: ${filePath}`);
|
|
602
|
+
try {
|
|
603
|
+
// Dynamic import for mammoth
|
|
604
|
+
const mammoth = await Promise.resolve().then(() => __importStar(require('mammoth')));
|
|
605
|
+
const result = await mammoth.convertToHtml({ path: filePath });
|
|
606
|
+
const html = result.value;
|
|
607
|
+
// Log any warnings from mammoth
|
|
608
|
+
if (result.messages.length > 0) {
|
|
609
|
+
logger.debug(`Mammoth warnings: ${result.messages.map(m => m.message).join(', ')}`);
|
|
610
|
+
}
|
|
611
|
+
// Create markdown with filename as title
|
|
612
|
+
let markdown = `# ${path.basename(filePath, '.docx')}\n\n`;
|
|
613
|
+
// Convert HTML to Markdown using turndown
|
|
614
|
+
const cleanHtml = (0, sanitize_html_1.default)(html, {
|
|
615
|
+
allowedTags: [
|
|
616
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
617
|
+
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
618
|
+
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'br'
|
|
619
|
+
],
|
|
620
|
+
allowedAttributes: {
|
|
621
|
+
'a': ['href'],
|
|
622
|
+
'pre': ['class'],
|
|
623
|
+
'code': ['class']
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
const convertedContent = this.turndownService.turndown(cleanHtml);
|
|
627
|
+
markdown += convertedContent;
|
|
628
|
+
// Clean up excessive line breaks
|
|
629
|
+
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
|
|
630
|
+
logger.debug(`Converted DOCX to ${markdown.length} characters of markdown`);
|
|
631
|
+
return markdown;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
logger.error(`Failed to convert DOCX ${filePath}:`, error);
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
424
638
|
async convertPdfToMarkdown(filePath, logger) {
|
|
425
639
|
logger.debug(`Converting PDF to markdown: ${filePath}`);
|
|
426
640
|
try {
|
|
@@ -611,6 +825,16 @@ class ContentProcessor {
|
|
|
611
825
|
logger.debug(`Processing PDF file: ${filePath}`);
|
|
612
826
|
processedContent = await this.convertPdfToMarkdown(filePath, logger);
|
|
613
827
|
}
|
|
828
|
+
else if (extension === '.doc') {
|
|
829
|
+
// Handle legacy Word DOC files
|
|
830
|
+
logger.debug(`Processing DOC file: ${filePath}`);
|
|
831
|
+
processedContent = await this.convertDocToMarkdown(filePath, logger);
|
|
832
|
+
}
|
|
833
|
+
else if (extension === '.docx') {
|
|
834
|
+
// Handle modern Word DOCX files
|
|
835
|
+
logger.debug(`Processing DOCX file: ${filePath}`);
|
|
836
|
+
processedContent = await this.convertDocxToMarkdown(filePath, logger);
|
|
837
|
+
}
|
|
614
838
|
else {
|
|
615
839
|
// Handle text-based files
|
|
616
840
|
content = fs.readFileSync(filePath, { encoding: encoding });
|
|
@@ -662,86 +886,410 @@ class ContentProcessor {
|
|
|
662
886
|
logger.error(`Error reading directory ${dirPath}:`, error);
|
|
663
887
|
}
|
|
664
888
|
}
|
|
889
|
+
async processCodeDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set(), options) {
|
|
890
|
+
const logger = parentLogger.child('code-directory-processor');
|
|
891
|
+
logger.info(`Processing code directory: ${dirPath}`);
|
|
892
|
+
const recursive = config.recursive !== undefined ? config.recursive : true;
|
|
893
|
+
const includeExtensions = config.include_extensions || [
|
|
894
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
895
|
+
'.py', '.go', '.rs', '.java', '.kt', '.kts', '.swift',
|
|
896
|
+
'.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
|
|
897
|
+
'.rb', '.php', '.scala', '.sql', '.sh', '.bash', '.zsh',
|
|
898
|
+
'.html', '.css', '.scss', '.sass', '.less',
|
|
899
|
+
'.json', '.yaml', '.yml', '.md'
|
|
900
|
+
];
|
|
901
|
+
const excludeExtensions = config.exclude_extensions || [];
|
|
902
|
+
const encoding = config.encoding || 'utf8';
|
|
903
|
+
let maxMtime = 0;
|
|
904
|
+
try {
|
|
905
|
+
const files = fs.readdirSync(dirPath);
|
|
906
|
+
let processedFiles = 0;
|
|
907
|
+
let skippedFiles = 0;
|
|
908
|
+
const allowedFiles = options?.allowedFiles;
|
|
909
|
+
const mtimeCutoff = options?.mtimeCutoff;
|
|
910
|
+
const trackFiles = options?.trackFiles;
|
|
911
|
+
for (const file of files) {
|
|
912
|
+
const filePath = path.join(dirPath, file);
|
|
913
|
+
const stat = fs.statSync(filePath);
|
|
914
|
+
if (visitedPaths.has(filePath)) {
|
|
915
|
+
logger.debug(`Skipping already visited path: ${filePath}`);
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
visitedPaths.add(filePath);
|
|
919
|
+
if (stat.isDirectory()) {
|
|
920
|
+
if (recursive) {
|
|
921
|
+
const childResult = await this.processCodeDirectory(filePath, config, processFileContent, logger, visitedPaths, options);
|
|
922
|
+
processedFiles += childResult.processedFiles;
|
|
923
|
+
skippedFiles += childResult.skippedFiles;
|
|
924
|
+
maxMtime = Math.max(maxMtime, childResult.maxMtime);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
logger.debug(`Skipping directory ${filePath} (recursive=false)`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else if (stat.isFile()) {
|
|
931
|
+
const extension = path.extname(file).toLowerCase();
|
|
932
|
+
if (excludeExtensions.includes(extension)) {
|
|
933
|
+
logger.debug(`Skipping file with excluded extension: ${filePath}`);
|
|
934
|
+
skippedFiles++;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
|
|
938
|
+
logger.debug(`Skipping file with non-included extension: ${filePath}`);
|
|
939
|
+
skippedFiles++;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
trackFiles?.add(filePath);
|
|
943
|
+
maxMtime = Math.max(maxMtime, stat.mtimeMs);
|
|
944
|
+
if (allowedFiles && !allowedFiles.has(filePath)) {
|
|
945
|
+
skippedFiles++;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (mtimeCutoff !== undefined && stat.mtimeMs <= mtimeCutoff) {
|
|
949
|
+
skippedFiles++;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
logger.info(`Reading file: ${filePath}`);
|
|
954
|
+
const content = fs.readFileSync(filePath, { encoding: encoding });
|
|
955
|
+
if (content.length > config.max_size) {
|
|
956
|
+
logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
|
|
957
|
+
skippedFiles++;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
await processFileContent(filePath, content);
|
|
961
|
+
processedFiles++;
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
logger.error(`Error processing file ${filePath}:`, error);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
logger.info(`Code directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
|
|
969
|
+
return { processedFiles, skippedFiles, maxMtime };
|
|
970
|
+
}
|
|
971
|
+
catch (error) {
|
|
972
|
+
logger.error(`Error reading code directory ${dirPath}:`, error);
|
|
973
|
+
return { processedFiles: 0, skippedFiles: 0, maxMtime };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async getTokenChunker(chunkSize) {
|
|
977
|
+
const cacheKey = `${chunkSize || 'default'}`;
|
|
978
|
+
const cached = this.tokenChunkerCache.get(cacheKey);
|
|
979
|
+
if (cached) {
|
|
980
|
+
return cached;
|
|
981
|
+
}
|
|
982
|
+
const chunkerPromise = (async () => {
|
|
983
|
+
const { TokenChunker } = await this.importChonkieModule('@chonkiejs/core');
|
|
984
|
+
return await TokenChunker.create({ chunkSize, tokenizer: 'character' });
|
|
985
|
+
})();
|
|
986
|
+
this.tokenChunkerCache.set(cacheKey, chunkerPromise);
|
|
987
|
+
return chunkerPromise;
|
|
988
|
+
}
|
|
989
|
+
async getCodeChunker(lang, chunkSize) {
|
|
990
|
+
const cacheKey = `${lang}:${chunkSize || 'default'}`;
|
|
991
|
+
const cached = this.codeChunkerCache.get(cacheKey);
|
|
992
|
+
if (cached) {
|
|
993
|
+
return cached;
|
|
994
|
+
}
|
|
995
|
+
const chunkerPromise = (async () => {
|
|
996
|
+
const tokenizer = await this.getTokenizer();
|
|
997
|
+
return await code_chunker_1.CodeChunker.create({
|
|
998
|
+
lang,
|
|
999
|
+
chunkSize,
|
|
1000
|
+
tokenCounter: async (text) => tokenizer.countTokens(text)
|
|
1001
|
+
});
|
|
1002
|
+
})();
|
|
1003
|
+
this.codeChunkerCache.set(cacheKey, chunkerPromise);
|
|
1004
|
+
return chunkerPromise;
|
|
1005
|
+
}
|
|
1006
|
+
async getTokenizer() {
|
|
1007
|
+
if (!this.tokenizerCache) {
|
|
1008
|
+
this.tokenizerCache = (async () => {
|
|
1009
|
+
const { Tokenizer } = await this.importChonkieModule('@chonkiejs/core');
|
|
1010
|
+
return await Tokenizer.create('character');
|
|
1011
|
+
})();
|
|
1012
|
+
}
|
|
1013
|
+
return this.tokenizerCache;
|
|
1014
|
+
}
|
|
1015
|
+
detectCodeLanguage(filePath) {
|
|
1016
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1017
|
+
const languageMap = {
|
|
1018
|
+
'.ts': 'typescript',
|
|
1019
|
+
'.tsx': 'typescript',
|
|
1020
|
+
'.js': 'javascript',
|
|
1021
|
+
'.jsx': 'javascript',
|
|
1022
|
+
'.mjs': 'javascript',
|
|
1023
|
+
'.cjs': 'javascript',
|
|
1024
|
+
'.py': 'python',
|
|
1025
|
+
'.go': 'go',
|
|
1026
|
+
'.rs': 'rust',
|
|
1027
|
+
'.java': 'java',
|
|
1028
|
+
'.kt': 'kotlin',
|
|
1029
|
+
'.kts': 'kotlin',
|
|
1030
|
+
'.swift': 'swift',
|
|
1031
|
+
'.c': 'c',
|
|
1032
|
+
'.cc': 'cpp',
|
|
1033
|
+
'.cpp': 'cpp',
|
|
1034
|
+
'.h': 'cpp',
|
|
1035
|
+
'.hpp': 'cpp',
|
|
1036
|
+
'.cs': 'csharp',
|
|
1037
|
+
'.rb': 'ruby',
|
|
1038
|
+
'.php': 'php',
|
|
1039
|
+
'.scala': 'scala',
|
|
1040
|
+
'.sql': 'sql',
|
|
1041
|
+
'.sh': 'bash',
|
|
1042
|
+
'.bash': 'bash',
|
|
1043
|
+
'.zsh': 'bash',
|
|
1044
|
+
'.html': 'html',
|
|
1045
|
+
'.css': 'css',
|
|
1046
|
+
'.scss': 'scss',
|
|
1047
|
+
'.sass': 'scss',
|
|
1048
|
+
'.less': 'css',
|
|
1049
|
+
'.json': 'json',
|
|
1050
|
+
'.yaml': 'yaml',
|
|
1051
|
+
'.yml': 'yaml',
|
|
1052
|
+
'.md': 'markdown'
|
|
1053
|
+
};
|
|
1054
|
+
return languageMap[extension];
|
|
1055
|
+
}
|
|
1056
|
+
async importChonkieModule(specifier) {
|
|
1057
|
+
// Use dynamic import() directly - preserved by TypeScript with target ES2020+
|
|
1058
|
+
// even in CommonJS mode, as Node.js supports import() in CJS contexts
|
|
1059
|
+
return Promise.resolve(`${specifier}`).then(s => __importStar(require(s)));
|
|
1060
|
+
}
|
|
1061
|
+
async chunkCode(code, sourceConfig, url, filePath, branch, repo) {
|
|
1062
|
+
const logger = this.logger.child('code-chunker');
|
|
1063
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
1064
|
+
const lang = this.detectCodeLanguage(filePath);
|
|
1065
|
+
let chunks;
|
|
1066
|
+
if (lang === 'markdown') {
|
|
1067
|
+
const markdownChunks = await this.chunkMarkdown(code, sourceConfig, url);
|
|
1068
|
+
for (const chunk of markdownChunks) {
|
|
1069
|
+
if (normalizedPath) {
|
|
1070
|
+
const filePrefix = `[File: ${normalizedPath}]\n`;
|
|
1071
|
+
const searchableText = filePrefix + chunk.content;
|
|
1072
|
+
const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
|
|
1073
|
+
chunk.content = searchableText;
|
|
1074
|
+
chunk.metadata.heading_hierarchy = [normalizedPath, ...chunk.metadata.heading_hierarchy.filter(Boolean)];
|
|
1075
|
+
chunk.metadata.section = normalizedPath;
|
|
1076
|
+
chunk.metadata.chunk_id = chunkId;
|
|
1077
|
+
chunk.metadata.hash = chunkId;
|
|
1078
|
+
}
|
|
1079
|
+
if (branch) {
|
|
1080
|
+
chunk.metadata.branch = branch;
|
|
1081
|
+
}
|
|
1082
|
+
if (repo) {
|
|
1083
|
+
chunk.metadata.repo = repo;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
logger.debug(`Chunked ${normalizedPath || url}: ${markdownChunks.length} chunks created.`);
|
|
1087
|
+
return markdownChunks;
|
|
1088
|
+
}
|
|
1089
|
+
if (lang) {
|
|
1090
|
+
try {
|
|
1091
|
+
const codeChunker = await this.getCodeChunker(lang, sourceConfig.chunk_size);
|
|
1092
|
+
chunks = await codeChunker.chunk(code);
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
logger.warn(`CodeChunker failed for ${normalizedPath || url}, falling back to token chunking:`, error);
|
|
1096
|
+
const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
|
|
1097
|
+
chunks = await chunker.chunk(code);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
|
|
1102
|
+
chunks = await chunker.chunk(code);
|
|
1103
|
+
}
|
|
1104
|
+
const documentChunks = [];
|
|
1105
|
+
let chunkCounter = 0;
|
|
1106
|
+
const headingHierarchy = normalizedPath ? [normalizedPath] : [];
|
|
1107
|
+
const contextPrefix = normalizedPath ? `[File: ${normalizedPath}]\n` : '';
|
|
1108
|
+
for (const chunk of chunks) {
|
|
1109
|
+
const content = chunk.text?.trim();
|
|
1110
|
+
if (!content) {
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const searchableText = contextPrefix + content;
|
|
1114
|
+
const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
|
|
1115
|
+
documentChunks.push({
|
|
1116
|
+
content: searchableText,
|
|
1117
|
+
metadata: {
|
|
1118
|
+
product_name: sourceConfig.product_name,
|
|
1119
|
+
version: sourceConfig.version,
|
|
1120
|
+
...(branch ? { branch } : {}),
|
|
1121
|
+
...(repo ? { repo } : {}),
|
|
1122
|
+
heading_hierarchy: headingHierarchy,
|
|
1123
|
+
section: normalizedPath || 'Code',
|
|
1124
|
+
chunk_id: chunkId,
|
|
1125
|
+
url: url,
|
|
1126
|
+
hash: chunkId,
|
|
1127
|
+
chunk_index: chunkCounter,
|
|
1128
|
+
total_chunks: 0
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
chunkCounter++;
|
|
1132
|
+
}
|
|
1133
|
+
const totalChunks = documentChunks.length;
|
|
1134
|
+
for (const chunk of documentChunks) {
|
|
1135
|
+
chunk.metadata.total_chunks = totalChunks;
|
|
1136
|
+
}
|
|
1137
|
+
logger.debug(`Chunked ${normalizedPath || url}: ${documentChunks.length} chunks created.`);
|
|
1138
|
+
return documentChunks;
|
|
1139
|
+
}
|
|
665
1140
|
async chunkMarkdown(markdown, sourceConfig, url) {
|
|
666
1141
|
const logger = this.logger.child('chunker');
|
|
667
|
-
|
|
1142
|
+
// --- Configuration ---
|
|
668
1143
|
const MAX_TOKENS = 1000;
|
|
1144
|
+
const MIN_TOKENS = 150; // 💡 Merges "OpenAI-compatible" sentence into the next block
|
|
1145
|
+
const OVERLAP_PERCENT = 0.1; // 10% overlap for large splits
|
|
669
1146
|
const chunks = [];
|
|
670
1147
|
const lines = markdown.split("\n");
|
|
671
|
-
let
|
|
1148
|
+
let buffer = "";
|
|
672
1149
|
let headingHierarchy = [];
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
lastTokens.shift();
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
if (subChunk) {
|
|
699
|
-
chunks.push(createDocumentChunk(subChunk, headingHierarchy));
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
else {
|
|
703
|
-
chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
|
|
704
|
-
}
|
|
1150
|
+
let bufferHeadings = []; // Track headings in current buffer
|
|
1151
|
+
let chunkCounter = 0; // Tracks chunk position within this page for ordering
|
|
1152
|
+
/**
|
|
1153
|
+
* Computes the topic hierarchy for merged content.
|
|
1154
|
+
* When merging sibling sections (same level), uses their parent heading.
|
|
1155
|
+
* Otherwise uses the current hierarchy.
|
|
1156
|
+
*/
|
|
1157
|
+
const computeTopicHierarchy = () => {
|
|
1158
|
+
if (bufferHeadings.length === 0) {
|
|
1159
|
+
return headingHierarchy;
|
|
1160
|
+
}
|
|
1161
|
+
// Find the deepest level (most recent headings)
|
|
1162
|
+
const deepestLevel = Math.max(...bufferHeadings.map(h => h.level));
|
|
1163
|
+
// Get all headings at the deepest level
|
|
1164
|
+
const deepestHeadings = bufferHeadings.filter(h => h.level === deepestLevel);
|
|
1165
|
+
// If we have multiple sibling headings at the deepest level, use their parent
|
|
1166
|
+
if (deepestHeadings.length > 1 && deepestLevel > 1) {
|
|
1167
|
+
// Use parent heading (one level up from the sibling headings)
|
|
1168
|
+
// headingHierarchy still contains the parent at index (deepestLevel - 2)
|
|
1169
|
+
// We want everything up to (but not including) the deepest level
|
|
1170
|
+
return headingHierarchy.slice(0, deepestLevel - 1);
|
|
705
1171
|
}
|
|
706
|
-
|
|
1172
|
+
// Single heading or different levels: use the current hierarchy
|
|
1173
|
+
// This reflects the most recent heading which is appropriate
|
|
1174
|
+
return headingHierarchy;
|
|
707
1175
|
};
|
|
1176
|
+
/**
|
|
1177
|
+
* Internal helper to create the final chunk object with injected context.
|
|
1178
|
+
*/
|
|
708
1179
|
const createDocumentChunk = (content, hierarchy) => {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1180
|
+
// 💡 BREADCRUMB INJECTION
|
|
1181
|
+
// We prepend the hierarchy to the text. This makes the vector highly relevant
|
|
1182
|
+
// to searches for parent topics even if the body doesn't mention them.
|
|
1183
|
+
const breadcrumbs = hierarchy.filter(h => h).join(" > ");
|
|
1184
|
+
const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
|
|
1185
|
+
const searchableText = contextPrefix + content.trim();
|
|
1186
|
+
const chunkId = utils_1.Utils.generateHash(searchableText);
|
|
1187
|
+
const chunk = {
|
|
1188
|
+
content: searchableText,
|
|
713
1189
|
metadata: {
|
|
714
1190
|
product_name: sourceConfig.product_name,
|
|
715
1191
|
version: sourceConfig.version,
|
|
716
|
-
heading_hierarchy:
|
|
1192
|
+
heading_hierarchy: hierarchy.filter(h => h),
|
|
717
1193
|
section: hierarchy[hierarchy.length - 1] || "Introduction",
|
|
718
1194
|
chunk_id: chunkId,
|
|
719
1195
|
url: url,
|
|
720
|
-
hash:
|
|
1196
|
+
hash: chunkId,
|
|
1197
|
+
chunk_index: Math.floor(chunkCounter),
|
|
1198
|
+
total_chunks: 0 // Placeholder, will be updated after all chunks are created
|
|
721
1199
|
}
|
|
722
1200
|
};
|
|
1201
|
+
chunkCounter++; // Increment for next chunk
|
|
1202
|
+
return chunk;
|
|
723
1203
|
};
|
|
1204
|
+
/**
|
|
1205
|
+
* Flushes the current buffer into the chunks array.
|
|
1206
|
+
* Uses sub-splitting logic if the buffer exceeds MAX_TOKENS.
|
|
1207
|
+
*/
|
|
1208
|
+
const flushBuffer = (force = false) => {
|
|
1209
|
+
const trimmedBuffer = buffer.trim();
|
|
1210
|
+
if (!trimmedBuffer)
|
|
1211
|
+
return;
|
|
1212
|
+
const tokenCount = utils_1.Utils.tokenize(trimmedBuffer).length;
|
|
1213
|
+
// 💡 SEMANTIC MERGING
|
|
1214
|
+
// If the current section is too short (like just a title or a one-liner),
|
|
1215
|
+
// we don't flush yet unless it's the end of the file (force=true).
|
|
1216
|
+
if (tokenCount < MIN_TOKENS && !force) {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
// Compute the appropriate topic hierarchy for merged content
|
|
1220
|
+
const topicHierarchy = computeTopicHierarchy();
|
|
1221
|
+
if (tokenCount > MAX_TOKENS) {
|
|
1222
|
+
// 💡 RECURSIVE OVERLAP SPLITTING
|
|
1223
|
+
// If the section is a massive guide, split it but keep headers on every sub-piece.
|
|
1224
|
+
const tokens = utils_1.Utils.tokenize(trimmedBuffer);
|
|
1225
|
+
const overlapSize = Math.floor(MAX_TOKENS * OVERLAP_PERCENT);
|
|
1226
|
+
for (let i = 0; i < tokens.length; i += (MAX_TOKENS - overlapSize)) {
|
|
1227
|
+
const subTokens = tokens.slice(i, i + MAX_TOKENS);
|
|
1228
|
+
const subContent = subTokens.join("");
|
|
1229
|
+
chunks.push(createDocumentChunk(subContent, topicHierarchy));
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
chunks.push(createDocumentChunk(trimmedBuffer, topicHierarchy));
|
|
1234
|
+
}
|
|
1235
|
+
buffer = ""; // Reset buffer after successful flush
|
|
1236
|
+
bufferHeadings = []; // Reset tracked headings
|
|
1237
|
+
};
|
|
1238
|
+
// --- Main Processing Loop ---
|
|
724
1239
|
for (const line of lines) {
|
|
725
|
-
|
|
726
|
-
|
|
1240
|
+
const isHeading = line.startsWith("#");
|
|
1241
|
+
if (isHeading) {
|
|
1242
|
+
// Update Hierarchy Stack for the new heading
|
|
727
1243
|
const levelMatch = line.match(/^(#+)/);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1244
|
+
const level = levelMatch ? levelMatch[1].length : 1;
|
|
1245
|
+
// Clean heading: remove markdown prefix and anchor links like [](#anchor-id)
|
|
1246
|
+
const headingText = line
|
|
1247
|
+
.replace(/^#+\s*/, "") // Remove ## prefix
|
|
1248
|
+
.replace(/\[.*?\]\(#[^)]*\)/g, "") // Remove [text](#anchor) patterns
|
|
1249
|
+
.replace(/\[\]\(#[^)]*\)/g, "") // Remove [](#anchor) patterns
|
|
1250
|
+
.trim();
|
|
1251
|
+
// Check if we should merge with previous content
|
|
1252
|
+
const currentTokenCount = utils_1.Utils.tokenize(buffer.trim()).length;
|
|
1253
|
+
const hasBufferContent = currentTokenCount > 0;
|
|
1254
|
+
const bufferIsSmall = currentTokenCount < MIN_TOKENS;
|
|
1255
|
+
// Only merge if:
|
|
1256
|
+
// 1. Buffer has content and is small
|
|
1257
|
+
// 2. Buffer has tracked headings (we're merging sections, not just content)
|
|
1258
|
+
// 3. New heading is at same or deeper level than the deepest heading in buffer (siblings or children)
|
|
1259
|
+
// If new heading is shallower (e.g., H2 after H3), it's a new section - flush first
|
|
1260
|
+
const deepestBufferLevel = bufferHeadings.length > 0
|
|
1261
|
+
? Math.max(...bufferHeadings.map(h => h.level))
|
|
1262
|
+
: 0;
|
|
1263
|
+
const shouldMerge = hasBufferContent && bufferIsSmall && bufferHeadings.length > 0 &&
|
|
1264
|
+
level >= deepestBufferLevel;
|
|
1265
|
+
if (!shouldMerge && hasBufferContent) {
|
|
1266
|
+
// Buffer is large enough OR new heading starts a new section - flush first
|
|
1267
|
+
flushBuffer();
|
|
736
1268
|
}
|
|
737
|
-
|
|
1269
|
+
// If shouldMerge is true, we keep the buffer and merge the sections
|
|
1270
|
+
// Reset hierarchy below this level (e.g., H2 reset should clear previous H3s)
|
|
1271
|
+
headingHierarchy = headingHierarchy.slice(0, level - 1);
|
|
1272
|
+
headingHierarchy[level - 1] = headingText;
|
|
1273
|
+
// Track this heading in the buffer
|
|
1274
|
+
bufferHeadings.push({ level, text: headingText });
|
|
1275
|
+
buffer += `${line}\n`;
|
|
738
1276
|
}
|
|
739
1277
|
else {
|
|
740
|
-
|
|
1278
|
+
buffer += `${line}\n`;
|
|
1279
|
+
// Safety valve: if a single section is huge, flush it periodically
|
|
1280
|
+
if (utils_1.Utils.tokenize(buffer).length >= MAX_TOKENS) {
|
|
1281
|
+
flushBuffer();
|
|
1282
|
+
}
|
|
741
1283
|
}
|
|
742
1284
|
}
|
|
743
|
-
|
|
744
|
-
|
|
1285
|
+
// Final sweep
|
|
1286
|
+
flushBuffer(true);
|
|
1287
|
+
// Update all chunks with the final total count
|
|
1288
|
+
const totalChunks = Math.floor(chunks.length);
|
|
1289
|
+
for (const chunk of chunks) {
|
|
1290
|
+
chunk.metadata.total_chunks = totalChunks;
|
|
1291
|
+
}
|
|
1292
|
+
logger.debug(`Chunking complete: ${chunks.length} rich context chunks created.`);
|
|
745
1293
|
return chunks;
|
|
746
1294
|
}
|
|
747
1295
|
}
|