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.
@@ -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) && !queue.includes(url)) {
210
- logger.debug(`Adding URL from sitemap to queue: ${url}`);
211
- queue.push(url);
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 content = await this.processPage(url, sourceConfig);
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, url);
262
- if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
263
- if (!queue.includes(fullUrl)) {
264
- queue.push(fullUrl);
265
- newLinksFound++;
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
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
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
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
385
- const cleanHtml = (0, sanitize_html_1.default)(article.content, {
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
- const markdown = this.turndownService.turndown(cleanHtml);
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
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
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 currentChunk = "";
1148
+ let buffer = "";
672
1149
  let headingHierarchy = [];
673
- const processChunk = () => {
674
- if (currentChunk.trim()) {
675
- const tokens = utils_1.Utils.tokenize(currentChunk);
676
- if (tokens.length > MAX_TOKENS) {
677
- logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
678
- let subChunk = "";
679
- let tokenCount = 0;
680
- const overlapSize = Math.floor(MAX_TOKENS * 0.05);
681
- let lastTokens = [];
682
- for (const token of tokens) {
683
- if (tokenCount + 1 > MAX_TOKENS) {
684
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
685
- subChunk = lastTokens.join("") + token;
686
- tokenCount = lastTokens.length + 1;
687
- lastTokens = [];
688
- }
689
- else {
690
- subChunk += token;
691
- tokenCount++;
692
- lastTokens.push(token);
693
- if (lastTokens.length > overlapSize) {
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
- currentChunk = "";
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
- const chunkId = utils_1.Utils.generateHash(content);
710
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
711
- return {
712
- content,
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: [...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: utils_1.Utils.generateHash(content)
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
- if (line.startsWith("#")) {
726
- processChunk();
1240
+ const isHeading = line.startsWith("#");
1241
+ if (isHeading) {
1242
+ // Update Hierarchy Stack for the new heading
727
1243
  const levelMatch = line.match(/^(#+)/);
728
- let level = levelMatch ? levelMatch[1].length : 1;
729
- const heading = line.replace(/^#+\s*/, "").trim();
730
- logger.debug(`Found heading (level ${level}): ${heading}`);
731
- while (headingHierarchy.length < level - 1) {
732
- headingHierarchy.push("");
733
- }
734
- if (level <= headingHierarchy.length) {
735
- headingHierarchy = headingHierarchy.slice(0, level - 1);
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
- headingHierarchy[level - 1] = heading;
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
- currentChunk += `${line}\n`;
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
- processChunk();
744
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
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
  }