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.
@@ -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
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
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
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
385
- const cleanHtml = (0, sanitize_html_1.default)(article.content, {
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
- const markdown = this.turndownService.turndown(cleanHtml);
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
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
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 currentChunk = "";
823
+ let buffer = "";
672
824
  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
- }
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
- currentChunk = "";
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
- const chunkId = utils_1.Utils.generateHash(content);
710
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
711
- return {
712
- content,
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: [...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: utils_1.Utils.generateHash(content)
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
- if (line.startsWith("#")) {
726
- processChunk();
915
+ const isHeading = line.startsWith("#");
916
+ if (isHeading) {
917
+ // Update Hierarchy Stack for the new heading
727
918
  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);
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
- headingHierarchy[level - 1] = heading;
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
- currentChunk += `${line}\n`;
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
- processChunk();
744
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
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
- const params = [
246
- new Float32Array(embedding),
247
- chunk.metadata.product_name,
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(params);
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([...params.slice(0, 8), chunk.metadata.chunk_id]);
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
- const currentTime = Math.floor(Date.now() / 1000);
139
- const waitTime = resetTime ? (resetTime - currentTime) * 1000 : delay * 2;
140
- logger.warn(`GitHub rate limit exceeded. Waiting ${waitTime / 1000}s`);
141
- await new Promise(res => setTimeout(res, waitTime));
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
- logger.error(`GitHub fetch failed: ${error.message}`);
145
- throw error;
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,