doc2vec 1.3.0 → 2.0.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 +153 -4
- package/dist/content-processor copy.js +819 -0
- package/dist/content-processor.js +283 -60
- package/dist/database.js +13 -17
- package/dist/doc2vec.js +48 -6
- 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/package.json +4 -2
- package/dist/embedding-providers.js +0 -157
|
@@ -347,14 +347,32 @@ class ContentProcessor {
|
|
|
347
347
|
// Original HTML page processing logic
|
|
348
348
|
let browser = null;
|
|
349
349
|
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';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
350
360
|
browser = await puppeteer_1.default.launch({
|
|
361
|
+
executablePath,
|
|
351
362
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
352
363
|
});
|
|
353
364
|
const page = await browser.newPage();
|
|
354
365
|
logger.debug(`Navigating to ${url}`);
|
|
355
366
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
356
367
|
const htmlContent = await page.evaluate(() => {
|
|
357
|
-
|
|
368
|
+
// 💡 Try specific content selectors first, then fall back to broader ones
|
|
369
|
+
const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
|
|
370
|
+
document.querySelector('.doc-content') || // Alternative docs pattern
|
|
371
|
+
document.querySelector('.markdown-body') || // GitHub-style
|
|
372
|
+
document.querySelector('article') || // Semantic article
|
|
373
|
+
document.querySelector('div[role="main"].document') ||
|
|
374
|
+
document.querySelector('main') ||
|
|
375
|
+
document.body;
|
|
358
376
|
return mainContentElement.innerHTML;
|
|
359
377
|
});
|
|
360
378
|
if (htmlContent.length > sourceConfig.max_size) {
|
|
@@ -370,10 +388,24 @@ class ContentProcessor {
|
|
|
370
388
|
pre.setAttribute('data-readable-content-score', '100');
|
|
371
389
|
this.markCodeParents(pre.parentElement);
|
|
372
390
|
});
|
|
391
|
+
// 💡 Extract H1s BEFORE Readability - it often strips them as "chrome"
|
|
392
|
+
// We'll inject them back after Readability processing
|
|
393
|
+
const h1Elements = document.querySelectorAll('h1');
|
|
394
|
+
const extractedH1s = [];
|
|
395
|
+
logger.debug(`[Readability Debug] Found ${h1Elements.length} H1 elements before Readability`);
|
|
396
|
+
h1Elements.forEach((h1, index) => {
|
|
397
|
+
const h1Text = h1.textContent?.trim() || '';
|
|
398
|
+
// Skip empty H1s or icon-only H1s (like "link" anchors)
|
|
399
|
+
if (h1Text && h1Text.length > 3 && !h1Text.match(/^(link|#|menu|close)$/i)) {
|
|
400
|
+
extractedH1s.push(h1Text);
|
|
401
|
+
logger.debug(`[Readability Debug] Extracted H1[${index}]: "${h1Text.substring(0, 50)}..."`);
|
|
402
|
+
}
|
|
403
|
+
h1.classList.add('original-h1');
|
|
404
|
+
});
|
|
373
405
|
logger.debug(`Applying Readability to extract main content`);
|
|
374
406
|
const reader = new readability_1.Readability(document, {
|
|
375
407
|
charThreshold: 20,
|
|
376
|
-
classesToPreserve: ['article-content'],
|
|
408
|
+
classesToPreserve: ['article-content', 'original-h1'],
|
|
377
409
|
});
|
|
378
410
|
const article = reader.parse();
|
|
379
411
|
if (!article) {
|
|
@@ -381,8 +413,35 @@ class ContentProcessor {
|
|
|
381
413
|
await browser.close();
|
|
382
414
|
return null;
|
|
383
415
|
}
|
|
384
|
-
|
|
385
|
-
|
|
416
|
+
// Debug: Log what Readability extracted
|
|
417
|
+
logger.debug(`[Readability Debug] article.title: "${article.title}"`);
|
|
418
|
+
logger.debug(`[Readability Debug] article.content length: ${article.content?.length}`);
|
|
419
|
+
logger.debug(`[Readability Debug] article.content starts with: "${article.content?.substring(0, 200)}..."`);
|
|
420
|
+
logger.debug(`[Readability Debug] Contains H1 tag: ${article.content?.includes('<h1')}`);
|
|
421
|
+
logger.debug(`[Readability Debug] Contains H2 tag: ${article.content?.includes('<h2')}`);
|
|
422
|
+
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
|
|
424
|
+
const articleDom = new jsdom_1.JSDOM(article.content);
|
|
425
|
+
const articleDoc = articleDom.window.document;
|
|
426
|
+
const originalH1Elements = articleDoc.querySelectorAll('.original-h1');
|
|
427
|
+
logger.debug(`[Readability Debug] Found ${originalH1Elements.length} elements with .original-h1 class to restore`);
|
|
428
|
+
originalH1Elements.forEach((heading, index) => {
|
|
429
|
+
logger.debug(`[Readability Debug] Restoring[${index}]: tagName=${heading.tagName}, text="${heading.textContent?.trim().substring(0, 50)}..."`);
|
|
430
|
+
// Create a new H1 element with the same content
|
|
431
|
+
const h1 = articleDoc.createElement('h1');
|
|
432
|
+
h1.innerHTML = heading.innerHTML;
|
|
433
|
+
// Copy other attributes except class
|
|
434
|
+
Array.from(heading.attributes).forEach(attr => {
|
|
435
|
+
if (attr.name !== 'class') {
|
|
436
|
+
h1.setAttribute(attr.name, attr.value);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
heading.replaceWith(h1);
|
|
440
|
+
});
|
|
441
|
+
const restoredContent = articleDoc.body.innerHTML;
|
|
442
|
+
logger.debug(`[Readability Debug] Restored content contains H1: ${restoredContent.includes('<h1')}`);
|
|
443
|
+
logger.debug(`Sanitizing HTML (${restoredContent.length} chars)`);
|
|
444
|
+
const cleanHtml = (0, sanitize_html_1.default)(restoredContent, {
|
|
386
445
|
allowedTags: [
|
|
387
446
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
388
447
|
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
@@ -397,7 +456,26 @@ class ContentProcessor {
|
|
|
397
456
|
}
|
|
398
457
|
});
|
|
399
458
|
logger.debug(`Converting HTML to Markdown`);
|
|
400
|
-
|
|
459
|
+
let markdown = this.turndownService.turndown(cleanHtml);
|
|
460
|
+
// 💡 Inject extracted H1s back if they're not in the markdown
|
|
461
|
+
// Readability often strips them as "page chrome"
|
|
462
|
+
// Use article.title as fallback if no H1 was extracted
|
|
463
|
+
const pageTitle = extractedH1s.length > 0 ? extractedH1s[0] : (article.title?.trim() || '');
|
|
464
|
+
if (pageTitle) {
|
|
465
|
+
// Check if markdown already starts with this exact H1 (allowing for leading whitespace)
|
|
466
|
+
const normalizedTitle = pageTitle.replace(/\s+/g, ' ');
|
|
467
|
+
const markdownFirstLine = markdown.trimStart().split('\n')[0] || '';
|
|
468
|
+
const existingH1Match = markdownFirstLine.match(/^#\s+(.+)$/);
|
|
469
|
+
const existingH1Text = existingH1Match ? existingH1Match[1].replace(/\s+/g, ' ').trim() : '';
|
|
470
|
+
// Only inject if markdown doesn't already start with this H1
|
|
471
|
+
if (!existingH1Match || existingH1Text !== normalizedTitle) {
|
|
472
|
+
markdown = `# ${pageTitle}\n\n${markdown}`;
|
|
473
|
+
logger.debug(`[Readability Debug] Injected page title as H1: "${pageTitle}"`);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
logger.debug(`[Readability Debug] H1 "${pageTitle}" already present in markdown`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
401
479
|
logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
|
|
402
480
|
return markdown;
|
|
403
481
|
}
|
|
@@ -421,6 +499,68 @@ class ContentProcessor {
|
|
|
421
499
|
}
|
|
422
500
|
this.markCodeParents(node.parentElement);
|
|
423
501
|
}
|
|
502
|
+
async convertDocToMarkdown(filePath, logger) {
|
|
503
|
+
logger.debug(`Converting DOC to markdown: ${filePath}`);
|
|
504
|
+
try {
|
|
505
|
+
// Dynamic import for word-extractor
|
|
506
|
+
const WordExtractor = (await Promise.resolve().then(() => __importStar(require('word-extractor')))).default;
|
|
507
|
+
const extractor = new WordExtractor();
|
|
508
|
+
const extracted = await extractor.extract(filePath);
|
|
509
|
+
const text = extracted.getBody();
|
|
510
|
+
// Create markdown with filename as title
|
|
511
|
+
let markdown = `# ${path.basename(filePath, '.doc')}\n\n`;
|
|
512
|
+
// Clean up the text and add to markdown
|
|
513
|
+
const cleanedText = text
|
|
514
|
+
.replace(/\r\n/g, '\n') // Normalize line endings
|
|
515
|
+
.replace(/\n{3,}/g, '\n\n') // Remove excessive line breaks
|
|
516
|
+
.trim();
|
|
517
|
+
markdown += cleanedText;
|
|
518
|
+
logger.debug(`Converted DOC to ${markdown.length} characters of markdown`);
|
|
519
|
+
return markdown;
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
logger.error(`Failed to convert DOC ${filePath}:`, error);
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async convertDocxToMarkdown(filePath, logger) {
|
|
527
|
+
logger.debug(`Converting DOCX to markdown: ${filePath}`);
|
|
528
|
+
try {
|
|
529
|
+
// Dynamic import for mammoth
|
|
530
|
+
const mammoth = await Promise.resolve().then(() => __importStar(require('mammoth')));
|
|
531
|
+
const result = await mammoth.convertToHtml({ path: filePath });
|
|
532
|
+
const html = result.value;
|
|
533
|
+
// Log any warnings from mammoth
|
|
534
|
+
if (result.messages.length > 0) {
|
|
535
|
+
logger.debug(`Mammoth warnings: ${result.messages.map(m => m.message).join(', ')}`);
|
|
536
|
+
}
|
|
537
|
+
// Create markdown with filename as title
|
|
538
|
+
let markdown = `# ${path.basename(filePath, '.docx')}\n\n`;
|
|
539
|
+
// Convert HTML to Markdown using turndown
|
|
540
|
+
const cleanHtml = (0, sanitize_html_1.default)(html, {
|
|
541
|
+
allowedTags: [
|
|
542
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
543
|
+
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
544
|
+
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'br'
|
|
545
|
+
],
|
|
546
|
+
allowedAttributes: {
|
|
547
|
+
'a': ['href'],
|
|
548
|
+
'pre': ['class'],
|
|
549
|
+
'code': ['class']
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
const convertedContent = this.turndownService.turndown(cleanHtml);
|
|
553
|
+
markdown += convertedContent;
|
|
554
|
+
// Clean up excessive line breaks
|
|
555
|
+
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
|
|
556
|
+
logger.debug(`Converted DOCX to ${markdown.length} characters of markdown`);
|
|
557
|
+
return markdown;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
logger.error(`Failed to convert DOCX ${filePath}:`, error);
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
424
564
|
async convertPdfToMarkdown(filePath, logger) {
|
|
425
565
|
logger.debug(`Converting PDF to markdown: ${filePath}`);
|
|
426
566
|
try {
|
|
@@ -611,6 +751,16 @@ class ContentProcessor {
|
|
|
611
751
|
logger.debug(`Processing PDF file: ${filePath}`);
|
|
612
752
|
processedContent = await this.convertPdfToMarkdown(filePath, logger);
|
|
613
753
|
}
|
|
754
|
+
else if (extension === '.doc') {
|
|
755
|
+
// Handle legacy Word DOC files
|
|
756
|
+
logger.debug(`Processing DOC file: ${filePath}`);
|
|
757
|
+
processedContent = await this.convertDocToMarkdown(filePath, logger);
|
|
758
|
+
}
|
|
759
|
+
else if (extension === '.docx') {
|
|
760
|
+
// Handle modern Word DOCX files
|
|
761
|
+
logger.debug(`Processing DOCX file: ${filePath}`);
|
|
762
|
+
processedContent = await this.convertDocxToMarkdown(filePath, logger);
|
|
763
|
+
}
|
|
614
764
|
else {
|
|
615
765
|
// Handle text-based files
|
|
616
766
|
content = fs.readFileSync(filePath, { encoding: encoding });
|
|
@@ -664,84 +814,157 @@ class ContentProcessor {
|
|
|
664
814
|
}
|
|
665
815
|
async chunkMarkdown(markdown, sourceConfig, url) {
|
|
666
816
|
const logger = this.logger.child('chunker');
|
|
667
|
-
|
|
817
|
+
// --- Configuration ---
|
|
668
818
|
const MAX_TOKENS = 1000;
|
|
819
|
+
const MIN_TOKENS = 150; // 💡 Merges "OpenAI-compatible" sentence into the next block
|
|
820
|
+
const OVERLAP_PERCENT = 0.1; // 10% overlap for large splits
|
|
669
821
|
const chunks = [];
|
|
670
822
|
const lines = markdown.split("\n");
|
|
671
|
-
let
|
|
823
|
+
let buffer = "";
|
|
672
824
|
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
|
-
}
|
|
825
|
+
let bufferHeadings = []; // Track headings in current buffer
|
|
826
|
+
let chunkCounter = 0; // Tracks chunk position within this page for ordering
|
|
827
|
+
/**
|
|
828
|
+
* Computes the topic hierarchy for merged content.
|
|
829
|
+
* When merging sibling sections (same level), uses their parent heading.
|
|
830
|
+
* Otherwise uses the current hierarchy.
|
|
831
|
+
*/
|
|
832
|
+
const computeTopicHierarchy = () => {
|
|
833
|
+
if (bufferHeadings.length === 0) {
|
|
834
|
+
return headingHierarchy;
|
|
835
|
+
}
|
|
836
|
+
// Find the deepest level (most recent headings)
|
|
837
|
+
const deepestLevel = Math.max(...bufferHeadings.map(h => h.level));
|
|
838
|
+
// Get all headings at the deepest level
|
|
839
|
+
const deepestHeadings = bufferHeadings.filter(h => h.level === deepestLevel);
|
|
840
|
+
// If we have multiple sibling headings at the deepest level, use their parent
|
|
841
|
+
if (deepestHeadings.length > 1 && deepestLevel > 1) {
|
|
842
|
+
// Use parent heading (one level up from the sibling headings)
|
|
843
|
+
// headingHierarchy still contains the parent at index (deepestLevel - 2)
|
|
844
|
+
// We want everything up to (but not including) the deepest level
|
|
845
|
+
return headingHierarchy.slice(0, deepestLevel - 1);
|
|
705
846
|
}
|
|
706
|
-
|
|
847
|
+
// Single heading or different levels: use the current hierarchy
|
|
848
|
+
// This reflects the most recent heading which is appropriate
|
|
849
|
+
return headingHierarchy;
|
|
707
850
|
};
|
|
851
|
+
/**
|
|
852
|
+
* Internal helper to create the final chunk object with injected context.
|
|
853
|
+
*/
|
|
708
854
|
const createDocumentChunk = (content, hierarchy) => {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
855
|
+
// 💡 BREADCRUMB INJECTION
|
|
856
|
+
// We prepend the hierarchy to the text. This makes the vector highly relevant
|
|
857
|
+
// to searches for parent topics even if the body doesn't mention them.
|
|
858
|
+
const breadcrumbs = hierarchy.filter(h => h).join(" > ");
|
|
859
|
+
const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
|
|
860
|
+
const searchableText = contextPrefix + content.trim();
|
|
861
|
+
const chunkId = utils_1.Utils.generateHash(searchableText);
|
|
862
|
+
const chunk = {
|
|
863
|
+
content: searchableText,
|
|
713
864
|
metadata: {
|
|
714
865
|
product_name: sourceConfig.product_name,
|
|
715
866
|
version: sourceConfig.version,
|
|
716
|
-
heading_hierarchy:
|
|
867
|
+
heading_hierarchy: hierarchy.filter(h => h),
|
|
717
868
|
section: hierarchy[hierarchy.length - 1] || "Introduction",
|
|
718
869
|
chunk_id: chunkId,
|
|
719
870
|
url: url,
|
|
720
|
-
hash:
|
|
871
|
+
hash: chunkId,
|
|
872
|
+
chunk_index: Math.floor(chunkCounter),
|
|
873
|
+
total_chunks: 0 // Placeholder, will be updated after all chunks are created
|
|
721
874
|
}
|
|
722
875
|
};
|
|
876
|
+
chunkCounter++; // Increment for next chunk
|
|
877
|
+
return chunk;
|
|
878
|
+
};
|
|
879
|
+
/**
|
|
880
|
+
* Flushes the current buffer into the chunks array.
|
|
881
|
+
* Uses sub-splitting logic if the buffer exceeds MAX_TOKENS.
|
|
882
|
+
*/
|
|
883
|
+
const flushBuffer = (force = false) => {
|
|
884
|
+
const trimmedBuffer = buffer.trim();
|
|
885
|
+
if (!trimmedBuffer)
|
|
886
|
+
return;
|
|
887
|
+
const tokenCount = utils_1.Utils.tokenize(trimmedBuffer).length;
|
|
888
|
+
// 💡 SEMANTIC MERGING
|
|
889
|
+
// If the current section is too short (like just a title or a one-liner),
|
|
890
|
+
// we don't flush yet unless it's the end of the file (force=true).
|
|
891
|
+
if (tokenCount < MIN_TOKENS && !force) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
// Compute the appropriate topic hierarchy for merged content
|
|
895
|
+
const topicHierarchy = computeTopicHierarchy();
|
|
896
|
+
if (tokenCount > MAX_TOKENS) {
|
|
897
|
+
// 💡 RECURSIVE OVERLAP SPLITTING
|
|
898
|
+
// If the section is a massive guide, split it but keep headers on every sub-piece.
|
|
899
|
+
const tokens = utils_1.Utils.tokenize(trimmedBuffer);
|
|
900
|
+
const overlapSize = Math.floor(MAX_TOKENS * OVERLAP_PERCENT);
|
|
901
|
+
for (let i = 0; i < tokens.length; i += (MAX_TOKENS - overlapSize)) {
|
|
902
|
+
const subTokens = tokens.slice(i, i + MAX_TOKENS);
|
|
903
|
+
const subContent = subTokens.join("");
|
|
904
|
+
chunks.push(createDocumentChunk(subContent, topicHierarchy));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
chunks.push(createDocumentChunk(trimmedBuffer, topicHierarchy));
|
|
909
|
+
}
|
|
910
|
+
buffer = ""; // Reset buffer after successful flush
|
|
911
|
+
bufferHeadings = []; // Reset tracked headings
|
|
723
912
|
};
|
|
913
|
+
// --- Main Processing Loop ---
|
|
724
914
|
for (const line of lines) {
|
|
725
|
-
|
|
726
|
-
|
|
915
|
+
const isHeading = line.startsWith("#");
|
|
916
|
+
if (isHeading) {
|
|
917
|
+
// Update Hierarchy Stack for the new heading
|
|
727
918
|
const levelMatch = line.match(/^(#+)/);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
919
|
+
const level = levelMatch ? levelMatch[1].length : 1;
|
|
920
|
+
// Clean heading: remove markdown prefix and anchor links like [](#anchor-id)
|
|
921
|
+
const headingText = line
|
|
922
|
+
.replace(/^#+\s*/, "") // Remove ## prefix
|
|
923
|
+
.replace(/\[.*?\]\(#[^)]*\)/g, "") // Remove [text](#anchor) patterns
|
|
924
|
+
.replace(/\[\]\(#[^)]*\)/g, "") // Remove [](#anchor) patterns
|
|
925
|
+
.trim();
|
|
926
|
+
// Check if we should merge with previous content
|
|
927
|
+
const currentTokenCount = utils_1.Utils.tokenize(buffer.trim()).length;
|
|
928
|
+
const hasBufferContent = currentTokenCount > 0;
|
|
929
|
+
const bufferIsSmall = currentTokenCount < MIN_TOKENS;
|
|
930
|
+
// Only merge if:
|
|
931
|
+
// 1. Buffer has content and is small
|
|
932
|
+
// 2. Buffer has tracked headings (we're merging sections, not just content)
|
|
933
|
+
// 3. New heading is at same or deeper level than the deepest heading in buffer (siblings or children)
|
|
934
|
+
// If new heading is shallower (e.g., H2 after H3), it's a new section - flush first
|
|
935
|
+
const deepestBufferLevel = bufferHeadings.length > 0
|
|
936
|
+
? Math.max(...bufferHeadings.map(h => h.level))
|
|
937
|
+
: 0;
|
|
938
|
+
const shouldMerge = hasBufferContent && bufferIsSmall && bufferHeadings.length > 0 &&
|
|
939
|
+
level >= deepestBufferLevel;
|
|
940
|
+
if (!shouldMerge && hasBufferContent) {
|
|
941
|
+
// Buffer is large enough OR new heading starts a new section - flush first
|
|
942
|
+
flushBuffer();
|
|
736
943
|
}
|
|
737
|
-
|
|
944
|
+
// If shouldMerge is true, we keep the buffer and merge the sections
|
|
945
|
+
// Reset hierarchy below this level (e.g., H2 reset should clear previous H3s)
|
|
946
|
+
headingHierarchy = headingHierarchy.slice(0, level - 1);
|
|
947
|
+
headingHierarchy[level - 1] = headingText;
|
|
948
|
+
// Track this heading in the buffer
|
|
949
|
+
bufferHeadings.push({ level, text: headingText });
|
|
950
|
+
buffer += `${line}\n`;
|
|
738
951
|
}
|
|
739
952
|
else {
|
|
740
|
-
|
|
953
|
+
buffer += `${line}\n`;
|
|
954
|
+
// Safety valve: if a single section is huge, flush it periodically
|
|
955
|
+
if (utils_1.Utils.tokenize(buffer).length >= MAX_TOKENS) {
|
|
956
|
+
flushBuffer();
|
|
957
|
+
}
|
|
741
958
|
}
|
|
742
959
|
}
|
|
743
|
-
|
|
744
|
-
|
|
960
|
+
// Final sweep
|
|
961
|
+
flushBuffer(true);
|
|
962
|
+
// Update all chunks with the final total count
|
|
963
|
+
const totalChunks = Math.floor(chunks.length);
|
|
964
|
+
for (const chunk of chunks) {
|
|
965
|
+
chunk.metadata.total_chunks = totalChunks;
|
|
966
|
+
}
|
|
967
|
+
logger.debug(`Chunking complete: ${chunks.length} rich context chunks created.`);
|
|
745
968
|
return chunks;
|
|
746
969
|
}
|
|
747
970
|
}
|
package/dist/database.js
CHANGED
|
@@ -64,7 +64,9 @@ class DatabaseManager {
|
|
|
64
64
|
chunk_id TEXT UNIQUE,
|
|
65
65
|
content TEXT,
|
|
66
66
|
url TEXT,
|
|
67
|
-
hash TEXT
|
|
67
|
+
hash TEXT,
|
|
68
|
+
chunk_index INTEGER,
|
|
69
|
+
total_chunks INTEGER
|
|
68
70
|
);
|
|
69
71
|
`);
|
|
70
72
|
logger.info(`SQLite database initialized successfully`);
|
|
@@ -226,12 +228,12 @@ class DatabaseManager {
|
|
|
226
228
|
static prepareSQLiteStatements(db) {
|
|
227
229
|
return {
|
|
228
230
|
insertStmt: db.prepare(`
|
|
229
|
-
INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
|
|
230
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
231
|
+
INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash, chunk_index, total_chunks)
|
|
232
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
231
233
|
`),
|
|
232
234
|
checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
|
|
233
235
|
updateStmt: db.prepare(`
|
|
234
|
-
UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
|
|
236
|
+
UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?, chunk_index = ?, total_chunks = ?
|
|
235
237
|
WHERE chunk_id = ?
|
|
236
238
|
`),
|
|
237
239
|
getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
|
|
@@ -242,22 +244,14 @@ class DatabaseManager {
|
|
|
242
244
|
const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
|
|
243
245
|
const hash = chunkHash || utils_1.Utils.generateHash(chunk.content);
|
|
244
246
|
const transaction = db.transaction(() => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
chunk.metadata.version,
|
|
249
|
-
JSON.stringify(chunk.metadata.heading_hierarchy),
|
|
250
|
-
chunk.metadata.section,
|
|
251
|
-
chunk.metadata.chunk_id,
|
|
252
|
-
chunk.content,
|
|
253
|
-
chunk.metadata.url,
|
|
254
|
-
hash
|
|
255
|
-
];
|
|
247
|
+
// Use BigInt for true integer representation in SQLite vec0
|
|
248
|
+
const chunkIndex = BigInt(chunk.metadata.chunk_index | 0);
|
|
249
|
+
const totalChunks = BigInt(chunk.metadata.total_chunks | 0);
|
|
256
250
|
try {
|
|
257
|
-
insertStmt.run(
|
|
251
|
+
insertStmt.run(new Float32Array(embedding), chunk.metadata.product_name, chunk.metadata.version, JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.metadata.chunk_id, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks);
|
|
258
252
|
}
|
|
259
253
|
catch (error) {
|
|
260
|
-
updateStmt.run(
|
|
254
|
+
updateStmt.run(new Float32Array(embedding), chunk.metadata.product_name, chunk.metadata.version, JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks, chunk.metadata.chunk_id);
|
|
261
255
|
}
|
|
262
256
|
});
|
|
263
257
|
transaction();
|
|
@@ -288,6 +282,8 @@ class DatabaseManager {
|
|
|
288
282
|
url: chunk.metadata.url,
|
|
289
283
|
hash: hash,
|
|
290
284
|
original_chunk_id: chunk.metadata.chunk_id,
|
|
285
|
+
chunk_index: chunk.metadata.chunk_index,
|
|
286
|
+
total_chunks: chunk.metadata.total_chunks,
|
|
291
287
|
},
|
|
292
288
|
};
|
|
293
289
|
await client.upsert(collectionName, {
|
package/dist/doc2vec.js
CHANGED
|
@@ -123,26 +123,64 @@ class Doc2Vec {
|
|
|
123
123
|
const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
|
|
124
124
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
125
125
|
try {
|
|
126
|
+
// Only log on retries to reduce noise during pagination
|
|
127
|
+
if (attempt > 0) {
|
|
128
|
+
logger.debug(`GitHub API retry: ${url} (attempt ${attempt + 1}/${retries})`);
|
|
129
|
+
}
|
|
126
130
|
const response = await axios_1.default.get(url, {
|
|
127
131
|
headers: {
|
|
128
132
|
Authorization: `token ${GITHUB_TOKEN}`,
|
|
129
133
|
Accept: 'application/vnd.github.v3+json',
|
|
130
134
|
},
|
|
131
135
|
params,
|
|
136
|
+
timeout: 30000, // 30 second timeout
|
|
132
137
|
});
|
|
133
138
|
return response.data;
|
|
134
139
|
}
|
|
135
140
|
catch (error) {
|
|
141
|
+
// Enhanced error logging for debugging
|
|
142
|
+
const errorDetails = {
|
|
143
|
+
code: error.code,
|
|
144
|
+
message: error.message,
|
|
145
|
+
status: error.response?.status,
|
|
146
|
+
isTimeout: error.code === 'ECONNABORTED' || error.message?.includes('timeout'),
|
|
147
|
+
isNetworkError: !error.response && error.code,
|
|
148
|
+
};
|
|
149
|
+
logger.debug(`GitHub API error details: ${JSON.stringify(errorDetails)}`);
|
|
136
150
|
if (error.response && error.response.status === 403) {
|
|
151
|
+
// Check if this is actually a rate limit error
|
|
152
|
+
const rateLimitRemaining = error.response.headers['x-ratelimit-remaining'];
|
|
137
153
|
const resetTime = error.response.headers['x-ratelimit-reset'];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
if (rateLimitRemaining === '0' && resetTime) {
|
|
155
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
156
|
+
const resetTimestamp = parseInt(resetTime, 10);
|
|
157
|
+
let waitTime = (resetTimestamp - currentTime) * 1000;
|
|
158
|
+
// Ensure waitTime is at least 1 second (in case resetTime is in the past)
|
|
159
|
+
if (waitTime < 1000) {
|
|
160
|
+
waitTime = 1000;
|
|
161
|
+
}
|
|
162
|
+
logger.warn(`GitHub rate limit exceeded. Waiting ${Math.ceil(waitTime / 1000)}s (attempt ${attempt + 1}/${retries})`);
|
|
163
|
+
await new Promise(res => setTimeout(res, waitTime));
|
|
164
|
+
// Retry the request after waiting
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// Other 403 errors (e.g., forbidden access)
|
|
169
|
+
logger.error(`GitHub API returned 403 (not rate limit): ${error.message}`);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
142
172
|
}
|
|
143
173
|
else {
|
|
144
|
-
|
|
145
|
-
|
|
174
|
+
// For non-403 errors, wait before retrying (exponential backoff)
|
|
175
|
+
if (attempt < retries - 1) {
|
|
176
|
+
const backoffDelay = delay * Math.pow(2, attempt);
|
|
177
|
+
logger.warn(`GitHub fetch failed (attempt ${attempt + 1}/${retries}): ${error.message}. Retrying in ${backoffDelay}ms`);
|
|
178
|
+
await new Promise(res => setTimeout(res, backoffDelay));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
logger.error(`GitHub fetch failed after ${retries} attempts: ${error.message} (code: ${error.code || 'unknown'})`);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
146
184
|
}
|
|
147
185
|
}
|
|
148
186
|
}
|
|
@@ -154,6 +192,10 @@ class Doc2Vec {
|
|
|
154
192
|
const perPage = 100;
|
|
155
193
|
const sinceTimestamp = new Date(sinceDate);
|
|
156
194
|
while (true) {
|
|
195
|
+
// Log progress every 10 pages to reduce noise
|
|
196
|
+
if (page === 1 || page % 10 === 0) {
|
|
197
|
+
logger.debug(`Fetching issues page ${page}... (${issues.length} issues so far)`);
|
|
198
|
+
}
|
|
157
199
|
const data = await fetchWithRetry(GITHUB_API_URL, {
|
|
158
200
|
per_page: perPage,
|
|
159
201
|
page,
|