doc2vec 2.0.0 → 2.3.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`);
@@ -220,70 +247,126 @@ class ContentProcessor {
220
247
  let pdfProcessedCount = 0;
221
248
  let errorCount = 0;
222
249
  let hasNetworkErrors = false;
223
- while (queue.length > 0) {
224
- const url = queue.shift();
225
- if (!url)
226
- continue;
227
- const normalizedUrl = utils_1.Utils.normalizeUrl(url);
228
- if (visitedUrls.has(normalizedUrl))
229
- continue;
230
- visitedUrls.add(normalizedUrl);
231
- if (!utils_1.Utils.shouldProcessUrl(url)) {
232
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
233
- skippedCount++;
234
- continue;
250
+ // Launch a single browser instance and reuse one page (tab) for all URLs
251
+ let browser = null;
252
+ let page = null;
253
+ const launchBrowser = async () => {
254
+ let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
255
+ if (!executablePath) {
256
+ if (fs.existsSync('/usr/bin/chromium')) {
257
+ executablePath = '/usr/bin/chromium';
258
+ }
259
+ else if (fs.existsSync('/usr/bin/chromium-browser')) {
260
+ executablePath = '/usr/bin/chromium-browser';
261
+ }
235
262
  }
236
- try {
237
- logger.info(`Crawling: ${url}`);
238
- const content = await this.processPage(url, sourceConfig);
239
- if (content !== null) {
240
- await processPageContent(url, content);
241
- if (utils_1.Utils.isPdfUrl(url)) {
242
- pdfProcessedCount++;
263
+ const b = await puppeteer_1.default.launch({
264
+ executablePath,
265
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
266
+ });
267
+ const p = await b.newPage();
268
+ return { browser: b, page: p };
269
+ };
270
+ const ensureBrowser = async () => {
271
+ if (!browser || !browser.isConnected()) {
272
+ logger.info(browser ? 'Browser disconnected, relaunching...' : 'Launching browser...');
273
+ const launched = await launchBrowser();
274
+ browser = launched.browser;
275
+ page = launched.page;
276
+ }
277
+ return page;
278
+ };
279
+ try {
280
+ while (queue.length > 0) {
281
+ const url = queue.shift();
282
+ if (!url)
283
+ continue;
284
+ const normalizedUrl = utils_1.Utils.normalizeUrl(url);
285
+ if (visitedUrls.has(normalizedUrl))
286
+ continue;
287
+ visitedUrls.add(normalizedUrl);
288
+ if (!utils_1.Utils.shouldProcessUrl(url)) {
289
+ logger.debug(`Skipping URL with unsupported extension: ${url}`);
290
+ skippedCount++;
291
+ continue;
292
+ }
293
+ try {
294
+ logger.info(`Crawling: ${url}`);
295
+ const sources = referrers.get(url) ?? new Set([baseUrl]);
296
+ // For HTML pages, ensure the browser is running and pass the shared page
297
+ // For PDFs, processPage handles them without Puppeteer
298
+ const currentPage = utils_1.Utils.isPdfUrl(url) ? undefined : await ensureBrowser();
299
+ const result = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
300
+ if (status === 404) {
301
+ for (const source of sources) {
302
+ addBrokenLink(source, reportedUrl);
303
+ }
304
+ }
305
+ }, currentPage);
306
+ if (result.content !== null) {
307
+ await processPageContent(url, result.content);
308
+ if (utils_1.Utils.isPdfUrl(url)) {
309
+ pdfProcessedCount++;
310
+ }
311
+ else {
312
+ processedCount++;
313
+ }
243
314
  }
244
315
  else {
245
- processedCount++;
316
+ skippedSizeCount++;
246
317
  }
247
- }
248
- else {
249
- skippedSizeCount++;
250
- }
251
- // Only try to extract links from HTML pages, not PDFs
252
- if (!utils_1.Utils.isPdfUrl(url)) {
253
- const response = await axios_1.default.get(url);
254
- const $ = (0, cheerio_1.load)(response.data);
255
- logger.debug(`Finding links on page ${url}`);
256
- let newLinksFound = 0;
257
- $('a[href]').each((_, element) => {
258
- const href = $(element).attr('href');
259
- if (!href || href.startsWith('#') || href.startsWith('mailto:'))
260
- 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++;
318
+ // Use links extracted from the full rendered DOM by processPage
319
+ // (no separate axios request needed)
320
+ if (result.links.length > 0) {
321
+ const pageUrlForLinks = result.finalUrl || url;
322
+ logger.debug(`Finding links on page ${url}`);
323
+ let newLinksFound = 0;
324
+ for (const href of result.links) {
325
+ const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
326
+ if (fullUrl.startsWith(sourceConfig.url)) {
327
+ addReferrer(fullUrl, pageUrlForLinks);
328
+ if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
329
+ if (!queue.includes(fullUrl)) {
330
+ queue.push(fullUrl);
331
+ newLinksFound++;
332
+ }
333
+ }
266
334
  }
267
335
  }
268
- });
269
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
336
+ logger.debug(`Found ${newLinksFound} new links on ${url}`);
337
+ }
270
338
  }
271
- }
272
- catch (error) {
273
- logger.error(`Failed during processing or link discovery for ${url}:`, error);
274
- errorCount++;
275
- // Check if this is a network error (DNS resolution, connection issues, etc.)
276
- if (this.isNetworkError(error)) {
277
- hasNetworkErrors = true;
278
- logger.warn(`Network error detected for ${url}, this may affect cleanup decisions`);
339
+ catch (error) {
340
+ logger.error(`Failed during processing or link discovery for ${url}:`, error);
341
+ errorCount++;
342
+ const status = this.getHttpStatus(error);
343
+ if (status === 404) {
344
+ const sources = referrers.get(url) ?? new Set([baseUrl]);
345
+ for (const source of sources) {
346
+ addBrokenLink(source, url);
347
+ }
348
+ }
349
+ // Check if this is a network error (DNS resolution, connection issues, etc.)
350
+ if (this.isNetworkError(error)) {
351
+ hasNetworkErrors = true;
352
+ logger.warn(`Network error detected for ${url}, this may affect cleanup decisions`);
353
+ }
279
354
  }
280
355
  }
281
356
  }
357
+ finally {
358
+ // Close the shared browser instance when the crawl is done
359
+ const browserToClose = browser;
360
+ if (browserToClose && browserToClose.isConnected()) {
361
+ await browserToClose.close();
362
+ logger.debug('Shared browser closed after crawl completed');
363
+ }
364
+ }
282
365
  logger.info(`Crawl completed. HTML Pages: ${processedCount}, PDFs: ${pdfProcessedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
283
366
  if (hasNetworkErrors) {
284
367
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
285
368
  }
286
- return { hasNetworkErrors };
369
+ return { hasNetworkErrors, brokenLinks };
287
370
  }
288
371
  isNetworkError(error) {
289
372
  // Check for common network error patterns
@@ -324,7 +407,7 @@ class ContentProcessor {
324
407
  }
325
408
  return false;
326
409
  }
327
- async processPage(url, sourceConfig) {
410
+ async processPage(url, sourceConfig, onHttpStatus, existingPage) {
328
411
  const logger = this.logger.child('page-processor');
329
412
  logger.debug(`Processing content from ${url}`);
330
413
  // Check if this is a PDF URL
@@ -335,37 +418,74 @@ class ContentProcessor {
335
418
  // Check size limit for PDF content
336
419
  if (markdown.length > sourceConfig.max_size) {
337
420
  logger.warn(`PDF content (${markdown.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping ${url}.`);
338
- return null;
421
+ return { content: null, links: [], finalUrl: url };
339
422
  }
340
- return markdown;
423
+ return { content: markdown, links: [], finalUrl: url };
341
424
  }
342
425
  catch (error) {
426
+ const status = this.getHttpStatus(error);
427
+ if (status !== undefined && status >= 400) {
428
+ if (onHttpStatus) {
429
+ onHttpStatus(url, status);
430
+ }
431
+ throw error;
432
+ }
343
433
  logger.error(`Failed to process PDF ${url}:`, error);
344
- return null;
434
+ return { content: null, links: [], finalUrl: url };
345
435
  }
346
436
  }
347
- // Original HTML page processing logic
437
+ // HTML page processing logic
438
+ // If an existing page (tab) is provided, reuse it; otherwise launch a standalone browser
348
439
  let browser = null;
440
+ let page;
441
+ const ownsTheBrowser = !existingPage;
349
442
  try {
350
- // Use system Chromium if available (for Docker environments)
351
- let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
352
- if (!executablePath) {
353
- if (fs.existsSync('/usr/bin/chromium')) {
354
- executablePath = '/usr/bin/chromium';
355
- }
356
- else if (fs.existsSync('/usr/bin/chromium-browser')) {
357
- executablePath = '/usr/bin/chromium-browser';
443
+ if (existingPage) {
444
+ page = existingPage;
445
+ }
446
+ else {
447
+ // Standalone mode: launch a browser for this single page
448
+ let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
449
+ if (!executablePath) {
450
+ if (fs.existsSync('/usr/bin/chromium')) {
451
+ executablePath = '/usr/bin/chromium';
452
+ }
453
+ else if (fs.existsSync('/usr/bin/chromium-browser')) {
454
+ executablePath = '/usr/bin/chromium-browser';
455
+ }
358
456
  }
457
+ browser = await puppeteer_1.default.launch({
458
+ executablePath,
459
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
460
+ });
461
+ page = await browser.newPage();
359
462
  }
360
- browser = await puppeteer_1.default.launch({
361
- executablePath,
362
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
363
- });
364
- const page = await browser.newPage();
365
463
  logger.debug(`Navigating to ${url}`);
366
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
464
+ const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
465
+ const status = response?.status();
466
+ if (status !== undefined && status >= 400) {
467
+ const error = new Error(`Failed to load page: HTTP ${status}`);
468
+ error.status = status;
469
+ throw error;
470
+ }
471
+ // Get the final URL after any redirects
472
+ const finalUrl = page.url();
473
+ // Extract ALL links from the full rendered DOM before any content filtering
474
+ // This searches the entire document, not just the main content area
475
+ const links = await page.evaluate(() => {
476
+ const anchors = document.querySelectorAll('a[href]');
477
+ const hrefs = [];
478
+ anchors.forEach(a => {
479
+ const href = a.getAttribute('href');
480
+ if (href && !href.startsWith('#') && !href.startsWith('mailto:')) {
481
+ hrefs.push(href);
482
+ }
483
+ });
484
+ return hrefs;
485
+ });
486
+ logger.debug(`Extracted ${links.length} links from full DOM of ${url}`);
367
487
  const htmlContent = await page.evaluate(() => {
368
- // 💡 Try specific content selectors first, then fall back to broader ones
488
+ // Try specific content selectors first, then fall back to broader ones
369
489
  const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
370
490
  document.querySelector('.doc-content') || // Alternative docs pattern
371
491
  document.querySelector('.markdown-body') || // GitHub-style
@@ -377,8 +497,7 @@ class ContentProcessor {
377
497
  });
378
498
  if (htmlContent.length > sourceConfig.max_size) {
379
499
  logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
380
- await browser.close();
381
- return null;
500
+ return { content: null, links, finalUrl };
382
501
  }
383
502
  logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
384
503
  const dom = new jsdom_1.JSDOM(htmlContent);
@@ -388,7 +507,7 @@ class ContentProcessor {
388
507
  pre.setAttribute('data-readable-content-score', '100');
389
508
  this.markCodeParents(pre.parentElement);
390
509
  });
391
- // 💡 Extract H1s BEFORE Readability - it often strips them as "chrome"
510
+ // Extract H1s BEFORE Readability - it often strips them as "chrome"
392
511
  // We'll inject them back after Readability processing
393
512
  const h1Elements = document.querySelectorAll('h1');
394
513
  const extractedH1s = [];
@@ -410,8 +529,7 @@ class ContentProcessor {
410
529
  const article = reader.parse();
411
530
  if (!article) {
412
531
  logger.warn(`Failed to parse article content with Readability for ${url}`);
413
- await browser.close();
414
- return null;
532
+ return { content: null, links, finalUrl };
415
533
  }
416
534
  // Debug: Log what Readability extracted
417
535
  logger.debug(`[Readability Debug] article.title: "${article.title}"`);
@@ -420,7 +538,7 @@ class ContentProcessor {
420
538
  logger.debug(`[Readability Debug] Contains H1 tag: ${article.content?.includes('<h1')}`);
421
539
  logger.debug(`[Readability Debug] Contains H2 tag: ${article.content?.includes('<h2')}`);
422
540
  logger.debug(`[Readability Debug] Contains original-h1 class: ${article.content?.includes('original-h1')}`);
423
- // 💡 Restore H1s: find elements with our marker class and convert back from H2
541
+ // Restore H1s: find elements with our marker class and convert back from H2
424
542
  const articleDom = new jsdom_1.JSDOM(article.content);
425
543
  const articleDoc = articleDom.window.document;
426
544
  const originalH1Elements = articleDoc.querySelectorAll('.original-h1');
@@ -457,7 +575,7 @@ class ContentProcessor {
457
575
  });
458
576
  logger.debug(`Converting HTML to Markdown`);
459
577
  let markdown = this.turndownService.turndown(cleanHtml);
460
- // 💡 Inject extracted H1s back if they're not in the markdown
578
+ // Inject extracted H1s back if they're not in the markdown
461
579
  // Readability often strips them as "page chrome"
462
580
  // Use article.title as fallback if no H1 was extracted
463
581
  const pageTitle = extractedH1s.length > 0 ? extractedH1s[0] : (article.title?.trim() || '');
@@ -477,19 +595,36 @@ class ContentProcessor {
477
595
  }
478
596
  }
479
597
  logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
480
- return markdown;
598
+ return { content: markdown, links, finalUrl };
481
599
  }
482
600
  catch (error) {
601
+ const status = this.getHttpStatus(error);
602
+ if (status !== undefined && status >= 400) {
603
+ if (onHttpStatus) {
604
+ onHttpStatus(url, status);
605
+ }
606
+ throw error;
607
+ }
483
608
  logger.error(`Error processing page ${url}:`, error);
484
- return null;
609
+ return { content: null, links: [], finalUrl: url };
485
610
  }
486
611
  finally {
487
- if (browser && browser.isConnected()) {
612
+ // Only close the browser if we launched it ourselves (standalone mode)
613
+ if (ownsTheBrowser && browser && browser.isConnected()) {
488
614
  await browser.close();
489
615
  logger.debug(`Browser closed for ${url}`);
490
616
  }
491
617
  }
492
618
  }
619
+ getHttpStatus(error) {
620
+ if (typeof error?.status === 'number') {
621
+ return error.status;
622
+ }
623
+ if (typeof error?.response?.status === 'number') {
624
+ return error.response.status;
625
+ }
626
+ return undefined;
627
+ }
493
628
  markCodeParents(node) {
494
629
  if (!node)
495
630
  return;
@@ -812,6 +947,257 @@ class ContentProcessor {
812
947
  logger.error(`Error reading directory ${dirPath}:`, error);
813
948
  }
814
949
  }
950
+ async processCodeDirectory(dirPath, config, processFileContent, parentLogger, visitedPaths = new Set(), options) {
951
+ const logger = parentLogger.child('code-directory-processor');
952
+ logger.info(`Processing code directory: ${dirPath}`);
953
+ const recursive = config.recursive !== undefined ? config.recursive : true;
954
+ const includeExtensions = config.include_extensions || [
955
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
956
+ '.py', '.go', '.rs', '.java', '.kt', '.kts', '.swift',
957
+ '.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
958
+ '.rb', '.php', '.scala', '.sql', '.sh', '.bash', '.zsh',
959
+ '.html', '.css', '.scss', '.sass', '.less',
960
+ '.json', '.yaml', '.yml', '.md'
961
+ ];
962
+ const excludeExtensions = config.exclude_extensions || [];
963
+ const encoding = config.encoding || 'utf8';
964
+ let maxMtime = 0;
965
+ try {
966
+ const files = fs.readdirSync(dirPath);
967
+ let processedFiles = 0;
968
+ let skippedFiles = 0;
969
+ const allowedFiles = options?.allowedFiles;
970
+ const mtimeCutoff = options?.mtimeCutoff;
971
+ const trackFiles = options?.trackFiles;
972
+ for (const file of files) {
973
+ const filePath = path.join(dirPath, file);
974
+ const stat = fs.statSync(filePath);
975
+ if (visitedPaths.has(filePath)) {
976
+ logger.debug(`Skipping already visited path: ${filePath}`);
977
+ continue;
978
+ }
979
+ visitedPaths.add(filePath);
980
+ if (stat.isDirectory()) {
981
+ if (recursive) {
982
+ const childResult = await this.processCodeDirectory(filePath, config, processFileContent, logger, visitedPaths, options);
983
+ processedFiles += childResult.processedFiles;
984
+ skippedFiles += childResult.skippedFiles;
985
+ maxMtime = Math.max(maxMtime, childResult.maxMtime);
986
+ }
987
+ else {
988
+ logger.debug(`Skipping directory ${filePath} (recursive=false)`);
989
+ }
990
+ }
991
+ else if (stat.isFile()) {
992
+ const extension = path.extname(file).toLowerCase();
993
+ if (excludeExtensions.includes(extension)) {
994
+ logger.debug(`Skipping file with excluded extension: ${filePath}`);
995
+ skippedFiles++;
996
+ continue;
997
+ }
998
+ if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
999
+ logger.debug(`Skipping file with non-included extension: ${filePath}`);
1000
+ skippedFiles++;
1001
+ continue;
1002
+ }
1003
+ trackFiles?.add(filePath);
1004
+ maxMtime = Math.max(maxMtime, stat.mtimeMs);
1005
+ if (allowedFiles && !allowedFiles.has(filePath)) {
1006
+ skippedFiles++;
1007
+ continue;
1008
+ }
1009
+ if (mtimeCutoff !== undefined && stat.mtimeMs <= mtimeCutoff) {
1010
+ skippedFiles++;
1011
+ continue;
1012
+ }
1013
+ try {
1014
+ logger.info(`Reading file: ${filePath}`);
1015
+ const content = fs.readFileSync(filePath, { encoding: encoding });
1016
+ if (content.length > config.max_size) {
1017
+ logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
1018
+ skippedFiles++;
1019
+ continue;
1020
+ }
1021
+ await processFileContent(filePath, content);
1022
+ processedFiles++;
1023
+ }
1024
+ catch (error) {
1025
+ logger.error(`Error processing file ${filePath}:`, error);
1026
+ }
1027
+ }
1028
+ }
1029
+ logger.info(`Code directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
1030
+ return { processedFiles, skippedFiles, maxMtime };
1031
+ }
1032
+ catch (error) {
1033
+ logger.error(`Error reading code directory ${dirPath}:`, error);
1034
+ return { processedFiles: 0, skippedFiles: 0, maxMtime };
1035
+ }
1036
+ }
1037
+ async getTokenChunker(chunkSize) {
1038
+ const cacheKey = `${chunkSize || 'default'}`;
1039
+ const cached = this.tokenChunkerCache.get(cacheKey);
1040
+ if (cached) {
1041
+ return cached;
1042
+ }
1043
+ const chunkerPromise = (async () => {
1044
+ const { TokenChunker } = await this.importChonkieModule('@chonkiejs/core');
1045
+ return await TokenChunker.create({ chunkSize, tokenizer: 'character' });
1046
+ })();
1047
+ this.tokenChunkerCache.set(cacheKey, chunkerPromise);
1048
+ return chunkerPromise;
1049
+ }
1050
+ async getCodeChunker(lang, chunkSize) {
1051
+ const cacheKey = `${lang}:${chunkSize || 'default'}`;
1052
+ const cached = this.codeChunkerCache.get(cacheKey);
1053
+ if (cached) {
1054
+ return cached;
1055
+ }
1056
+ const chunkerPromise = (async () => {
1057
+ const tokenizer = await this.getTokenizer();
1058
+ return await code_chunker_1.CodeChunker.create({
1059
+ lang,
1060
+ chunkSize,
1061
+ tokenCounter: async (text) => tokenizer.countTokens(text)
1062
+ });
1063
+ })();
1064
+ this.codeChunkerCache.set(cacheKey, chunkerPromise);
1065
+ return chunkerPromise;
1066
+ }
1067
+ async getTokenizer() {
1068
+ if (!this.tokenizerCache) {
1069
+ this.tokenizerCache = (async () => {
1070
+ const { Tokenizer } = await this.importChonkieModule('@chonkiejs/core');
1071
+ return await Tokenizer.create('character');
1072
+ })();
1073
+ }
1074
+ return this.tokenizerCache;
1075
+ }
1076
+ detectCodeLanguage(filePath) {
1077
+ const extension = path.extname(filePath).toLowerCase();
1078
+ const languageMap = {
1079
+ '.ts': 'typescript',
1080
+ '.tsx': 'typescript',
1081
+ '.js': 'javascript',
1082
+ '.jsx': 'javascript',
1083
+ '.mjs': 'javascript',
1084
+ '.cjs': 'javascript',
1085
+ '.py': 'python',
1086
+ '.go': 'go',
1087
+ '.rs': 'rust',
1088
+ '.java': 'java',
1089
+ '.kt': 'kotlin',
1090
+ '.kts': 'kotlin',
1091
+ '.swift': 'swift',
1092
+ '.c': 'c',
1093
+ '.cc': 'cpp',
1094
+ '.cpp': 'cpp',
1095
+ '.h': 'cpp',
1096
+ '.hpp': 'cpp',
1097
+ '.cs': 'csharp',
1098
+ '.rb': 'ruby',
1099
+ '.php': 'php',
1100
+ '.scala': 'scala',
1101
+ '.sql': 'sql',
1102
+ '.sh': 'bash',
1103
+ '.bash': 'bash',
1104
+ '.zsh': 'bash',
1105
+ '.html': 'html',
1106
+ '.css': 'css',
1107
+ '.scss': 'scss',
1108
+ '.sass': 'scss',
1109
+ '.less': 'css',
1110
+ '.json': 'json',
1111
+ '.yaml': 'yaml',
1112
+ '.yml': 'yaml',
1113
+ '.md': 'markdown'
1114
+ };
1115
+ return languageMap[extension];
1116
+ }
1117
+ async importChonkieModule(specifier) {
1118
+ // Use dynamic import() directly - preserved by TypeScript with target ES2020+
1119
+ // even in CommonJS mode, as Node.js supports import() in CJS contexts
1120
+ return Promise.resolve(`${specifier}`).then(s => __importStar(require(s)));
1121
+ }
1122
+ async chunkCode(code, sourceConfig, url, filePath, branch, repo) {
1123
+ const logger = this.logger.child('code-chunker');
1124
+ const normalizedPath = filePath.replace(/\\/g, '/');
1125
+ const lang = this.detectCodeLanguage(filePath);
1126
+ let chunks;
1127
+ if (lang === 'markdown') {
1128
+ const markdownChunks = await this.chunkMarkdown(code, sourceConfig, url);
1129
+ for (const chunk of markdownChunks) {
1130
+ if (normalizedPath) {
1131
+ const filePrefix = `[File: ${normalizedPath}]\n`;
1132
+ const searchableText = filePrefix + chunk.content;
1133
+ const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
1134
+ chunk.content = searchableText;
1135
+ chunk.metadata.heading_hierarchy = [normalizedPath, ...chunk.metadata.heading_hierarchy.filter(Boolean)];
1136
+ chunk.metadata.section = normalizedPath;
1137
+ chunk.metadata.chunk_id = chunkId;
1138
+ chunk.metadata.hash = chunkId;
1139
+ }
1140
+ if (branch) {
1141
+ chunk.metadata.branch = branch;
1142
+ }
1143
+ if (repo) {
1144
+ chunk.metadata.repo = repo;
1145
+ }
1146
+ }
1147
+ logger.debug(`Chunked ${normalizedPath || url}: ${markdownChunks.length} chunks created.`);
1148
+ return markdownChunks;
1149
+ }
1150
+ if (lang) {
1151
+ try {
1152
+ const codeChunker = await this.getCodeChunker(lang, sourceConfig.chunk_size);
1153
+ chunks = await codeChunker.chunk(code);
1154
+ }
1155
+ catch (error) {
1156
+ logger.warn(`CodeChunker failed for ${normalizedPath || url}, falling back to token chunking:`, error);
1157
+ const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
1158
+ chunks = await chunker.chunk(code);
1159
+ }
1160
+ }
1161
+ else {
1162
+ const chunker = await this.getTokenChunker(sourceConfig.chunk_size);
1163
+ chunks = await chunker.chunk(code);
1164
+ }
1165
+ const documentChunks = [];
1166
+ let chunkCounter = 0;
1167
+ const headingHierarchy = normalizedPath ? [normalizedPath] : [];
1168
+ const contextPrefix = normalizedPath ? `[File: ${normalizedPath}]\n` : '';
1169
+ for (const chunk of chunks) {
1170
+ const content = chunk.text?.trim();
1171
+ if (!content) {
1172
+ continue;
1173
+ }
1174
+ const searchableText = contextPrefix + content;
1175
+ const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
1176
+ documentChunks.push({
1177
+ content: searchableText,
1178
+ metadata: {
1179
+ product_name: sourceConfig.product_name,
1180
+ version: sourceConfig.version,
1181
+ ...(branch ? { branch } : {}),
1182
+ ...(repo ? { repo } : {}),
1183
+ heading_hierarchy: headingHierarchy,
1184
+ section: normalizedPath || 'Code',
1185
+ chunk_id: chunkId,
1186
+ url: url,
1187
+ hash: chunkId,
1188
+ chunk_index: chunkCounter,
1189
+ total_chunks: 0
1190
+ }
1191
+ });
1192
+ chunkCounter++;
1193
+ }
1194
+ const totalChunks = documentChunks.length;
1195
+ for (const chunk of documentChunks) {
1196
+ chunk.metadata.total_chunks = totalChunks;
1197
+ }
1198
+ logger.debug(`Chunked ${normalizedPath || url}: ${documentChunks.length} chunks created.`);
1199
+ return documentChunks;
1200
+ }
815
1201
  async chunkMarkdown(markdown, sourceConfig, url) {
816
1202
  const logger = this.logger.child('chunker');
817
1203
  // --- Configuration ---