bluera-knowledge 0.33.1 → 0.34.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Clone repos, crawl docs, search locally. Fast, authoritative answers for AI coding agents.",
5
5
  "author": {
6
6
  "name": "Bluera Inc",
@@ -1,6 +1,4 @@
1
1
  {
2
- "skill_activation": false,
3
2
  "websearch_suggestions": false,
4
- "webfetch_suggestions": false,
5
- "bk_read_suggestions": false
3
+ "webfetch_suggestions": false
6
4
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [0.34.0](https://github.com/blueraai/bluera-knowledge/compare/v0.28.0...v0.34.0) (2026-03-08)
6
+
7
+
8
+ ### Features
9
+
10
+ * **activation:** rewrite MCP tool descriptions and simplify hooks for proactive BK usage ([0880aa7](https://github.com/blueraai/bluera-knowledge/commit/0880aa78aa8189957555c58d6e67a562cbae13cb))
11
+ * add statusline module with store count display ([4446501](https://github.com/blueraai/bluera-knowledge/commit/44465015a4734cd7c253bda51c98ce149561fda8))
12
+ * **eval:** 3-agent comparison with BK Grep agent and token tracking ([b3045a8](https://github.com/blueraai/bluera-knowledge/commit/b3045a8ae7df7a2e13ecdf992ff4d1055521460b))
13
+ * **eval:** add agent quality eval comparing with-BK vs without-BK answers ([d8c62d8](https://github.com/blueraai/bluera-knowledge/commit/d8c62d804bd10d572049f4f2a01deb30cb00ccf5))
14
+ * **hooks:** add PostToolUse hook for WebSearch BK suggestions ([d0420b4](https://github.com/blueraai/bluera-knowledge/commit/d0420b473c73092b5e6a28be2699f5fc40e090c3))
15
+ * **mcp:** add file count estimation and ETA to store creation responses ([3d71a30](https://github.com/blueraai/bluera-knowledge/commit/3d71a307275fa06b9810ed606583f4ec2acc5841))
16
+ * **mcp:** add stores:pull command for git pull + re-index ([7ca809c](https://github.com/blueraai/bluera-knowledge/commit/7ca809c3675be19d9d0e29e71c5f75d4f40fce35))
17
+ * **mcp:** optimize search-to-read workflow with store paths, relatedFiles, find-files intent, and file-based get_full_context ([4eb9be9](https://github.com/blueraai/bluera-knowledge/commit/4eb9be97598e475576603b2b9a565946197986ac))
18
+ * search infrastructure, benchmark framework, and model registry ([285ff2f](https://github.com/blueraai/bluera-knowledge/commit/285ff2f5574c4d53b61b31e84fdec43553364e98))
19
+ * **search:** add strong-signal FTS bypass, position-aware reranking, and query expansion ([c9b6eac](https://github.com/blueraai/bluera-knowledge/commit/c9b6eac7de9a6f17100b37879304df5a634b5559))
20
+ * training pipeline, evaluation gate, and experiment docs ([d90b395](https://github.com/blueraai/bluera-knowledge/commit/d90b395a330f4ab09ac6d71db77d659c415aa87d))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **ci:** remove simple mode from validation and lower coverage threshold ([7aea1ac](https://github.com/blueraai/bluera-knowledge/commit/7aea1ac466de8ee13c6dc85c00932838d41ff829))
26
+ * **config:** wire BK_MODEL env var override in ConfigService.load() ([be56426](https://github.com/blueraai/bluera-knowledge/commit/be564263176410c3eb1441bf85c4fdce48ddc7c4))
27
+ * **crawl:** fix nested session error and remove simple mode ([79447b4](https://github.com/blueraai/bluera-knowledge/commit/79447b4604e54760af433deb21e177f9a71cbe8e))
28
+ * **hooks:** drain stdin in shell hooks, register web-research hook, add settings.json ([1cff9b8](https://github.com/blueraai/bluera-knowledge/commit/1cff9b8ab38e732ffa6d9fb2de8b398fddc98302))
29
+ * **mcp:** add runtime files to package.json files array and improve bootstrap error on corrupt cache ([e195f93](https://github.com/blueraai/bluera-knowledge/commit/e195f93a4600c65bcd0fcbb45380aaa1d32bb356))
30
+ * **mcp:** prevent browser postinstall from crashing bootstrap ([e27fe02](https://github.com/blueraai/bluera-knowledge/commit/e27fe021bdb34c8ca5b8bf93720a176435e7973c))
31
+
5
32
  ## [0.33.1](https://github.com/blueraai/bluera-knowledge/compare/v0.28.0...v0.33.1) (2026-03-03)
6
33
 
7
34
 
@@ -2,7 +2,7 @@ import {
2
2
  createLogger,
3
3
  summarizePayload,
4
4
  truncateForLog
5
- } from "./chunk-3TB7TDVF.js";
5
+ } from "./chunk-FYHKBCIH.js";
6
6
 
7
7
  // src/crawl/intelligent-crawler.ts
8
8
  import { EventEmitter } from "events";
@@ -375,10 +375,13 @@ ${this.truncateMarkdown(markdown, 1e5)}`;
375
375
  args.push("--json-schema", JSON.stringify(jsonSchema));
376
376
  args.push("--output-format", "json");
377
377
  }
378
+ const cleanEnv = Object.fromEntries(
379
+ Object.entries(process.env).filter(([key]) => !key.startsWith("CLAUDE"))
380
+ );
378
381
  const proc = spawn(claudePath, args, {
379
382
  stdio: ["pipe", "pipe", "pipe"],
380
383
  cwd: process.cwd(),
381
- env: { ...process.env }
384
+ env: cleanEnv
382
385
  });
383
386
  let stdout = "";
384
387
  let stderr = "";
@@ -457,26 +460,6 @@ ${this.truncateMarkdown(markdown, 1e5)}`;
457
460
  }
458
461
  };
459
462
 
460
- // src/crawl/link-extractor.ts
461
- import * as cheerio2 from "cheerio";
462
- function extractLinks(html, baseUrl) {
463
- const $ = cheerio2.load(html);
464
- const links = /* @__PURE__ */ new Set();
465
- $("a[href]").each((_, el) => {
466
- const href = $(el).attr("href");
467
- if (href !== void 0 && href !== "") {
468
- try {
469
- const absoluteUrl = new URL(href, baseUrl).href;
470
- if (absoluteUrl.startsWith("http://") || absoluteUrl.startsWith("https://")) {
471
- links.add(absoluteUrl);
472
- }
473
- } catch {
474
- }
475
- }
476
- });
477
- return [...links];
478
- }
479
-
480
463
  // src/crawl/playwright-crawler.ts
481
464
  import { chromium } from "playwright";
482
465
  var logger2 = createLogger("playwright-crawler");
@@ -583,17 +566,21 @@ var IntelligentCrawler = class extends EventEmitter {
583
566
  this.stopped = false;
584
567
  }
585
568
  /**
586
- * Crawl a website with intelligent or simple mode
569
+ * Crawl a website using Claude-driven intelligent mode
587
570
  */
588
571
  async *crawl(seedUrl, options = {}) {
589
- const { crawlInstruction, extractInstruction, maxPages = 50, simple = false } = options;
572
+ const {
573
+ crawlInstruction = "crawl all pages linked from this URL",
574
+ extractInstruction,
575
+ maxPages = 50
576
+ } = options;
590
577
  this.visited.clear();
591
578
  this.stopped = false;
592
579
  logger3.info(
593
580
  {
594
581
  seedUrl,
595
582
  maxPages,
596
- mode: simple ? "simple" : crawlInstruction !== void 0 && crawlInstruction !== "" ? "intelligent" : "simple",
583
+ mode: "intelligent",
597
584
  hasExtractInstruction: extractInstruction !== void 0
598
585
  },
599
586
  "Starting crawl"
@@ -604,19 +591,14 @@ var IntelligentCrawler = class extends EventEmitter {
604
591
  totalPages: maxPages
605
592
  };
606
593
  this.emit("progress", startProgress);
607
- const useIntelligentMode = !simple && crawlInstruction !== void 0 && crawlInstruction !== "";
608
- if (useIntelligentMode) {
609
- yield* this.crawlIntelligent(
610
- seedUrl,
611
- crawlInstruction,
612
- extractInstruction,
613
- maxPages,
614
- options.useHeadless ?? true,
615
- options.preComputedStrategy
616
- );
617
- } else {
618
- yield* this.crawlSimple(seedUrl, extractInstruction, maxPages, options.useHeadless ?? true);
619
- }
594
+ yield* this.crawlIntelligent(
595
+ seedUrl,
596
+ crawlInstruction,
597
+ extractInstruction,
598
+ maxPages,
599
+ options.useHeadless ?? true,
600
+ options.preComputedStrategy
601
+ );
620
602
  logger3.info(
621
603
  {
622
604
  seedUrl,
@@ -706,72 +688,6 @@ var IntelligentCrawler = class extends EventEmitter {
706
688
  }
707
689
  }
708
690
  }
709
- /**
710
- * Simple mode: BFS crawling with depth limit
711
- */
712
- async *crawlSimple(seedUrl, extractInstruction, maxPages, useHeadless = true) {
713
- const queue = [{ url: seedUrl, depth: 0 }];
714
- const maxDepth = 2;
715
- let pagesVisited = 0;
716
- while (queue.length > 0 && pagesVisited < maxPages && !this.stopped) {
717
- const current = queue.shift();
718
- if (!current || this.visited.has(current.url) || current.depth > maxDepth) {
719
- continue;
720
- }
721
- try {
722
- const result = await this.crawlSinglePage(
723
- current.url,
724
- extractInstruction,
725
- pagesVisited,
726
- useHeadless
727
- );
728
- result.depth = current.depth;
729
- pagesVisited++;
730
- yield result;
731
- if (current.depth < maxDepth) {
732
- try {
733
- const links = await this.extractLinks(current.url, useHeadless);
734
- if (links.length === 0) {
735
- logger3.debug({ url: current.url }, "No links found - page may be a leaf node");
736
- } else {
737
- logger3.debug(
738
- { url: current.url, linkCount: links.length },
739
- "Links extracted from page"
740
- );
741
- }
742
- for (const link of links) {
743
- if (!this.visited.has(link) && this.isSameDomain(seedUrl, link)) {
744
- queue.push({ url: link, depth: current.depth + 1 });
745
- }
746
- }
747
- } catch (error) {
748
- const errorProgress = {
749
- type: "error",
750
- pagesVisited,
751
- totalPages: maxPages,
752
- currentUrl: current.url,
753
- message: `Failed to extract links from ${current.url}`,
754
- error: error instanceof Error ? error : new Error(String(error))
755
- };
756
- this.emit("progress", errorProgress);
757
- }
758
- }
759
- } catch (error) {
760
- const errorObj = error instanceof Error ? error : new Error(String(error));
761
- if (errorObj.message.includes("Extraction failed") || errorObj.message.includes("Claude CLI not available") || errorObj.message.includes("Headless fetch failed")) {
762
- throw errorObj;
763
- }
764
- const simpleErrorProgress = {
765
- type: "error",
766
- pagesVisited,
767
- totalPages: maxPages,
768
- currentUrl: current.url,
769
- error: errorObj
770
- };
771
- this.emit("progress", simpleErrorProgress);
772
- }
773
- }
774
- }
775
691
  /**
776
692
  * Crawl a single page: fetch, convert to markdown, optionally extract
777
693
  */
@@ -869,40 +785,6 @@ var IntelligentCrawler = class extends EventEmitter {
869
785
  );
870
786
  }
871
787
  }
872
- /**
873
- * Extract links from a page using Playwright or cheerio
874
- */
875
- async extractLinks(url, useHeadless = true) {
876
- try {
877
- if (useHeadless) {
878
- const result = await fetchWithPlaywright(url);
879
- return result.links;
880
- }
881
- const response = await axios.get(url, {
882
- timeout: this.config.timeout ?? DEFAULT_TIMEOUT2,
883
- headers: {
884
- "User-Agent": this.config.userAgent ?? DEFAULT_USER_AGENT
885
- }
886
- });
887
- return extractLinks(response.data, url);
888
- } catch (error) {
889
- const errorMessage = error instanceof Error ? error.message : String(error);
890
- logger3.error({ url, error: errorMessage }, "Failed to extract links");
891
- throw new Error(`Link extraction failed for ${url}: ${errorMessage}`);
892
- }
893
- }
894
- /**
895
- * Check if two URLs are from the same domain
896
- */
897
- isSameDomain(url1, url2) {
898
- try {
899
- const domain1 = new URL(url1).hostname.toLowerCase();
900
- const domain2 = new URL(url2).hostname.toLowerCase();
901
- return domain1 === domain2 || domain1.endsWith(`.${domain2}`) || domain2.endsWith(`.${domain1}`);
902
- } catch {
903
- return false;
904
- }
905
- }
906
788
  /**
907
789
  * Stop the crawler
908
790
  */
@@ -916,4 +798,4 @@ export {
916
798
  getCrawlStrategy,
917
799
  IntelligentCrawler
918
800
  };
919
- //# sourceMappingURL=chunk-KDZDLJUY.js.map
801
+ //# sourceMappingURL=chunk-4S6LWHKI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/crawl/intelligent-crawler.ts","../src/crawl/article-converter.ts","../src/crawl/markdown-utils.ts","../src/crawl/claude-client.ts","../src/crawl/playwright-crawler.ts"],"sourcesContent":["/**\n * Intelligent web crawler with natural language control\n */\n\nimport { EventEmitter } from 'node:events';\nimport axios from 'axios';\nimport { convertHtmlToMarkdown } from './article-converter.js';\nimport { ClaudeClient, type CrawlStrategy } from './claude-client.js';\nimport { fetchWithPlaywright, closeBrowser } from './playwright-crawler.js';\nimport { createLogger, summarizePayload } from '../logging/index.js';\n\nconst logger = createLogger('crawler');\n\n/**\n * Get crawl strategy from Claude CLI without initializing lancedb.\n * Call this BEFORE createServices() to avoid fork-safety issues with lancedb.\n *\n * LanceDB's native Rust code is not fork-safe. If we spawn Claude CLI\n * after lancedb is loaded, the fork corrupts lancedb's mutex state.\n */\nexport async function getCrawlStrategy(\n seedUrl: string,\n crawlInstruction: string,\n useHeadless: boolean = true\n): Promise<CrawlStrategy> {\n if (!ClaudeClient.isAvailable()) {\n throw new Error('Claude CLI not available: install Claude Code for intelligent crawling');\n }\n\n const client = new ClaudeClient();\n\n // Fetch seed HTML\n let seedHtml: string;\n if (useHeadless) {\n const headlessResult = await fetchWithPlaywright(seedUrl);\n seedHtml = headlessResult.html;\n } else {\n const response = await axios.get(seedUrl, {\n timeout: 30000,\n headers: {\n 'User-Agent':\n 'Mozilla/5.0 (compatible; BlueraKnowledge/1.0; +https://github.com/blueraai/bluera-knowledge)',\n },\n });\n seedHtml = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);\n }\n\n // Get strategy from Claude\n return client.determineCrawlUrls(seedUrl, seedHtml, crawlInstruction);\n}\n\nexport interface CrawlConfig {\n userAgent?: string; // User-Agent header for requests\n timeout?: number; // Per-page timeout in ms (default: 30000)\n maxConcurrency?: number; // Max concurrent requests (for future use)\n}\n\nexport interface CrawlOptions {\n crawlInstruction?: string; // Natural language: what to crawl\n extractInstruction?: string; // Natural language: what to extract\n maxPages?: number; // Max pages to crawl (default: 50)\n timeout?: number; // Per-page timeout in ms (default: 30000)\n useHeadless?: boolean; // Enable headless browser for JavaScript-rendered sites\n preComputedStrategy?: CrawlStrategy; // Pre-computed strategy (to avoid fork issues with lancedb)\n}\n\nexport interface CrawlResult {\n url: string;\n title?: string;\n markdown: string;\n extracted?: string;\n depth?: number;\n}\n\nexport interface CrawlProgress {\n type: 'start' | 'strategy' | 'page' | 'extraction' | 'complete' | 'error';\n pagesVisited: number;\n totalPages: number;\n currentUrl?: string;\n message?: string;\n error?: Error;\n}\n\n// Default values for crawl config\nconst DEFAULT_USER_AGENT =\n 'Mozilla/5.0 (compatible; BlueraKnowledge/1.0; +https://github.com/blueraai/bluera-knowledge)';\nconst DEFAULT_TIMEOUT = 30000;\n\n/**\n * Intelligent crawler that uses Claude CLI for strategy and extraction\n */\nexport class IntelligentCrawler extends EventEmitter {\n private readonly claudeClient: ClaudeClient;\n private readonly visited: Set<string>;\n private readonly config: CrawlConfig;\n private stopped: boolean;\n\n constructor(config?: CrawlConfig) {\n super();\n this.claudeClient = new ClaudeClient();\n this.visited = new Set();\n this.config = config ?? {};\n this.stopped = false;\n }\n\n /**\n * Crawl a website using Claude-driven intelligent mode\n */\n async *crawl(seedUrl: string, options: CrawlOptions = {}): AsyncIterable<CrawlResult> {\n const {\n crawlInstruction = 'crawl all pages linked from this URL',\n extractInstruction,\n maxPages = 50,\n } = options;\n\n this.visited.clear();\n this.stopped = false;\n\n logger.info(\n {\n seedUrl,\n maxPages,\n mode: 'intelligent',\n hasExtractInstruction: extractInstruction !== undefined,\n },\n 'Starting crawl'\n );\n\n const startProgress: CrawlProgress = {\n type: 'start',\n pagesVisited: 0,\n totalPages: maxPages,\n };\n this.emit('progress', startProgress);\n\n yield* this.crawlIntelligent(\n seedUrl,\n crawlInstruction,\n extractInstruction,\n maxPages,\n options.useHeadless ?? true,\n options.preComputedStrategy\n );\n\n logger.info(\n {\n seedUrl,\n pagesVisited: this.visited.size,\n },\n 'Crawl complete'\n );\n\n // Warn if crawl discovered far fewer pages than requested\n if (this.visited.size === 1 && maxPages > 1) {\n const warningProgress: CrawlProgress = {\n type: 'error',\n pagesVisited: this.visited.size,\n totalPages: maxPages,\n message: `Warning: Only crawled 1 page despite maxPages=${String(maxPages)}. Link discovery may have failed. If using --fast mode, try without it for JavaScript-heavy sites.`,\n error: new Error('Low page discovery'),\n };\n this.emit('progress', warningProgress);\n }\n\n const completeProgress: CrawlProgress = {\n type: 'complete',\n pagesVisited: this.visited.size,\n totalPages: this.visited.size,\n };\n this.emit('progress', completeProgress);\n }\n\n /**\n * Intelligent mode: Use Claude to determine which URLs to crawl\n */\n private async *crawlIntelligent(\n seedUrl: string,\n crawlInstruction: string,\n extractInstruction: string | undefined,\n maxPages: number,\n useHeadless: boolean = true,\n preComputedStrategy?: CrawlStrategy\n ): AsyncIterable<CrawlResult> {\n let strategy: CrawlStrategy;\n\n // Use pre-computed strategy if provided (avoids fork issues with lancedb)\n if (preComputedStrategy !== undefined) {\n strategy = preComputedStrategy;\n const strategyProgress: CrawlProgress = {\n type: 'strategy',\n pagesVisited: 0,\n totalPages: maxPages,\n message: `Using pre-computed strategy: ${String(strategy.urls.length)} URLs to crawl`,\n };\n this.emit('progress', strategyProgress);\n } else {\n // Check if Claude CLI is available before attempting intelligent mode\n if (!ClaudeClient.isAvailable()) {\n throw new Error('Claude CLI not available: install Claude Code for intelligent crawling');\n }\n\n try {\n // Step 1: Fetch seed page HTML\n const strategyStartProgress: CrawlProgress = {\n type: 'strategy',\n pagesVisited: 0,\n totalPages: maxPages,\n currentUrl: seedUrl,\n message: 'Analyzing page structure with Claude...',\n };\n this.emit('progress', strategyStartProgress);\n\n const seedHtml = await this.fetchHtml(seedUrl, useHeadless);\n\n // Step 2: Ask Claude which URLs to crawl (pass seedUrl for relative URL resolution)\n strategy = await this.claudeClient.determineCrawlUrls(seedUrl, seedHtml, crawlInstruction);\n\n const strategyCompleteProgress: CrawlProgress = {\n type: 'strategy',\n pagesVisited: 0,\n totalPages: maxPages,\n message: `Claude identified ${String(strategy.urls.length)} URLs to crawl: ${strategy.reasoning}`,\n };\n this.emit('progress', strategyCompleteProgress);\n } catch (error) {\n // Re-throw strategy errors - do not fall back silently\n throw error instanceof Error ? error : new Error(String(error));\n }\n }\n\n // Step 3: Crawl each URL from Claude's strategy\n let pagesVisited = 0;\n\n for (const url of strategy.urls) {\n if (this.stopped || pagesVisited >= maxPages) break;\n if (this.visited.has(url)) continue;\n\n try {\n const result = await this.crawlSinglePage(\n url,\n extractInstruction,\n pagesVisited,\n useHeadless\n );\n pagesVisited++;\n yield result;\n } catch (error) {\n const pageErrorProgress: CrawlProgress = {\n type: 'error',\n pagesVisited,\n totalPages: maxPages,\n currentUrl: url,\n error: error instanceof Error ? error : new Error(String(error)),\n };\n this.emit('progress', pageErrorProgress);\n }\n }\n }\n\n /**\n * Crawl a single page: fetch, convert to markdown, optionally extract\n */\n private async crawlSinglePage(\n url: string,\n extractInstruction: string | undefined,\n pagesVisited: number,\n useHeadless: boolean = true\n ): Promise<CrawlResult> {\n const pageProgress: CrawlProgress = {\n type: 'page',\n pagesVisited,\n totalPages: 0,\n currentUrl: url,\n };\n this.emit('progress', pageProgress);\n\n // Mark as visited\n this.visited.add(url);\n\n // Fetch HTML\n const html = await this.fetchHtml(url, useHeadless);\n\n // Convert to clean markdown using slurp-ai techniques\n // Note: convertHtmlToMarkdown throws on errors, no need to check success\n const conversion = await convertHtmlToMarkdown(html, url);\n\n logger.debug(\n {\n url,\n title: conversion.title,\n markdownLength: conversion.markdown.length,\n },\n 'Article converted to markdown'\n );\n\n let extracted: string | undefined;\n\n // Optional: Extract specific information using Claude\n if (extractInstruction !== undefined && extractInstruction !== '') {\n // Throw if extraction requested but Claude CLI isn't available\n if (!ClaudeClient.isAvailable()) {\n throw new Error('Claude CLI not available: install Claude Code for extraction');\n }\n\n const extractionProgress: CrawlProgress = {\n type: 'extraction',\n pagesVisited,\n totalPages: 0,\n currentUrl: url,\n };\n this.emit('progress', extractionProgress);\n\n extracted = await this.claudeClient.extractContent(conversion.markdown, extractInstruction);\n }\n\n return {\n url,\n ...(conversion.title !== undefined && { title: conversion.title }),\n markdown: conversion.markdown,\n ...(extracted !== undefined && { extracted }),\n };\n }\n\n /**\n * Fetch HTML content from a URL\n */\n private async fetchHtml(url: string, useHeadless: boolean = true): Promise<string> {\n const startTime = Date.now();\n logger.debug({ url, useHeadless }, 'Fetching HTML');\n\n if (useHeadless) {\n try {\n const result = await fetchWithPlaywright(url);\n const durationMs = Date.now() - startTime;\n logger.info(\n {\n url,\n useHeadless: true,\n durationMs,\n ...summarizePayload(result.html, 'raw-html', url),\n },\n 'Raw HTML fetched'\n );\n return result.html;\n } catch (error) {\n // Wrap with distinctive message for error categorization\n throw new Error(\n `Headless fetch failed: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n // Original axios implementation for static sites\n try {\n const response = await axios.get<string>(url, {\n timeout: this.config.timeout ?? DEFAULT_TIMEOUT,\n headers: {\n 'User-Agent': this.config.userAgent ?? DEFAULT_USER_AGENT,\n },\n });\n\n const durationMs = Date.now() - startTime;\n logger.info(\n {\n url,\n useHeadless: false,\n durationMs,\n ...summarizePayload(response.data, 'raw-html', url),\n },\n 'Raw HTML fetched'\n );\n\n return response.data;\n } catch (error) {\n logger.error(\n { url, error: error instanceof Error ? error.message : String(error) },\n 'Failed to fetch HTML'\n );\n throw new Error(\n `Failed to fetch ${url}: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n /**\n * Stop the crawler\n */\n async stop(): Promise<void> {\n this.stopped = true;\n await closeBrowser();\n }\n}\n","/**\n * Article converter using @extractus/article-extractor and Turndown\n * Produces clean markdown from HTML using slurp-ai techniques\n */\n\nimport { extractFromHtml } from '@extractus/article-extractor';\nimport TurndownService from 'turndown';\nimport { gfm } from 'turndown-plugin-gfm';\nimport { preprocessHtmlForCodeBlocks, cleanupMarkdown } from './markdown-utils.js';\nimport { createLogger, truncateForLog } from '../logging/index.js';\n\nconst logger = createLogger('article-converter');\n\nexport interface ConversionResult {\n markdown: string;\n title?: string;\n}\n\n/**\n * Convert HTML to clean markdown using best practices from slurp-ai\n *\n * Pipeline:\n * 1. Extract main article content (strips navigation, ads, boilerplate)\n * 2. Preprocess HTML (handle MkDocs code blocks)\n * 3. Convert to markdown with Turndown + GFM\n * 4. Cleanup markdown (regex patterns)\n */\nexport async function convertHtmlToMarkdown(html: string, url: string): Promise<ConversionResult> {\n logger.debug({ url, htmlLength: html.length }, 'Starting HTML conversion');\n\n try {\n // Step 1: Extract main article content\n let articleHtml: string;\n let title: string | undefined;\n\n try {\n const article = await extractFromHtml(html, url);\n if (article?.content !== undefined && article.content !== '') {\n articleHtml = article.content;\n title = article.title !== undefined && article.title !== '' ? article.title : undefined;\n logger.debug(\n {\n url,\n title,\n extractedLength: articleHtml.length,\n usedFullHtml: false,\n },\n 'Article content extracted'\n );\n } else {\n // Fallback to full HTML if extraction fails\n articleHtml = html;\n logger.debug(\n { url, usedFullHtml: true },\n 'Article extraction returned empty, using full HTML'\n );\n }\n } catch (extractError) {\n // Fallback to full HTML if extraction fails\n articleHtml = html;\n logger.debug(\n {\n url,\n usedFullHtml: true,\n error: extractError instanceof Error ? extractError.message : String(extractError),\n },\n 'Article extraction failed, using full HTML'\n );\n }\n\n // Step 2: Preprocess HTML for code blocks\n const preprocessed = preprocessHtmlForCodeBlocks(articleHtml);\n\n // Step 3: Configure Turndown with custom rules\n const turndownService = new TurndownService({\n headingStyle: 'atx', // Use # style headings\n codeBlockStyle: 'fenced', // Use ``` style code blocks\n fence: '```',\n emDelimiter: '*',\n strongDelimiter: '**',\n linkStyle: 'inlined',\n });\n\n // Add GitHub Flavored Markdown support (tables, strikethrough, task lists)\n turndownService.use(gfm);\n\n // Custom rule for headings with anchors (from slurp-ai)\n turndownService.addRule('headingsWithAnchors', {\n filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],\n replacement(content: string, node: HTMLElement): string {\n const level = Number(node.nodeName.charAt(1));\n const hashes = '#'.repeat(level);\n const cleanContent = content\n .replace(/\\[\\]\\([^)]*\\)/g, '') // Remove empty links\n .replace(/\\s+/g, ' ') // Normalize whitespace\n .trim();\n return cleanContent !== '' ? `\\n\\n${hashes} ${cleanContent}\\n\\n` : '';\n },\n });\n\n // Convert to markdown\n const rawMarkdown = turndownService.turndown(preprocessed);\n\n // Step 4: Cleanup markdown with comprehensive regex patterns\n const markdown = cleanupMarkdown(rawMarkdown);\n\n logger.debug(\n {\n url,\n title,\n rawMarkdownLength: rawMarkdown.length,\n finalMarkdownLength: markdown.length,\n },\n 'HTML to markdown conversion complete'\n );\n\n // Log markdown preview at trace level\n logger.trace(\n {\n url,\n markdownPreview: truncateForLog(markdown, 1000),\n },\n 'Markdown content preview'\n );\n\n return {\n markdown,\n ...(title !== undefined && { title }),\n };\n } catch (error) {\n logger.error(\n {\n url,\n error: error instanceof Error ? error.message : String(error),\n },\n 'HTML to markdown conversion failed'\n );\n\n // Re-throw errors - do not return graceful degradation\n throw error instanceof Error ? error : new Error(String(error));\n }\n}\n","/**\n * Markdown conversion utilities ported from slurp-ai\n * Source: https://github.com/ratacat/slurp-ai\n *\n * These utilities handle complex documentation site patterns (MkDocs, Sphinx, etc.)\n * and produce clean, well-formatted markdown.\n */\n\nimport * as cheerio from 'cheerio';\n\n/**\n * Detect language from code element class names.\n * Handles various class naming patterns from different highlighters.\n */\nfunction detectLanguageFromClass(className: string | undefined): string {\n if (className === undefined || className === '') return '';\n\n // Common patterns: \"language-python\", \"lang-js\", \"highlight-python\", \"python\", \"hljs language-python\"\n const patterns = [\n /language-(\\w+)/i,\n /lang-(\\w+)/i,\n /highlight-(\\w+)/i,\n /hljs\\s+(\\w+)/i,\n /^(\\w+)$/i,\n ];\n\n for (const pattern of patterns) {\n const match = className.match(pattern);\n if (match?.[1] !== undefined) {\n const lang = match[1].toLowerCase();\n // Filter out common non-language classes\n if (!['hljs', 'highlight', 'code', 'pre', 'block', 'inline'].includes(lang)) {\n return lang;\n }\n }\n }\n\n return '';\n}\n\n/**\n * Escape HTML special characters for safe embedding in HTML.\n */\nfunction escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n}\n\n/**\n * Preprocess HTML to handle MkDocs/Material theme code blocks.\n *\n * MkDocs wraps code in tables for line numbers:\n * <table><tbody><tr><td>line numbers</td><td><pre><code>code</code></pre></td></tr></tbody></table>\n *\n * This function converts them to standard <pre><code> blocks that Turndown handles correctly.\n * Also strips syntax highlighting spans and empty anchors from code.\n */\nexport function preprocessHtmlForCodeBlocks(html: string): string {\n if (!html || typeof html !== 'string') return html;\n\n const $ = cheerio.load(html);\n\n // Handle MkDocs/Material table-wrapped code blocks\n $('table').each((_i, table) => {\n const $table = $(table);\n\n // Check if this table contains a code block\n const $codeCell = $table.find('td pre code, td div pre code');\n\n if ($codeCell.length > 0) {\n // This is a code block table - extract the code\n const $pre = $codeCell.closest('pre');\n const $code = $codeCell.first();\n\n // Get language from class\n let language = detectLanguageFromClass($code.attr('class'));\n if (!language) {\n language = detectLanguageFromClass($pre.attr('class'));\n }\n\n // Get the text content, stripping all inner HTML tags\n const codeText = $code.text();\n\n // Create a clean pre > code block\n const cleanPre = `<pre><code class=\"language-${language}\">${escapeHtml(codeText)}</code></pre>`;\n\n // Replace the entire table with the clean code block\n $table.replaceWith(cleanPre);\n }\n });\n\n // Strip empty anchor tags used for line numbers\n $('pre a, code a').each((_i, anchor) => {\n const $anchor = $(anchor);\n if (!$anchor.text().trim()) {\n $anchor.remove();\n }\n });\n\n // Strip syntax highlighting spans inside code blocks, keeping only text\n $('pre span, code span').each((_i, span) => {\n const $span = $(span);\n $span.replaceWith($span.text());\n });\n\n // Handle standalone pre blocks that might have spans/anchors\n $('pre').each((_i, pre) => {\n const $pre = $(pre);\n // If this pre has a code child, it was already processed\n if ($pre.find('code').length === 0) {\n // Direct pre without code - get text content\n const text = $pre.text();\n const lang = detectLanguageFromClass($pre.attr('class'));\n $pre.html(`<code class=\"language-${lang}\">${escapeHtml(text)}</code>`);\n }\n });\n\n return $.html();\n}\n\n/**\n * Apply comprehensive cleanup rules to markdown content.\n *\n * Formatting rules:\n * - Double newlines between paragraphs and headings\n * - Double newlines before lists when preceded by normal text\n * - Single newlines between list items\n * - No blank lines inside code blocks\n */\nexport function cleanupMarkdown(markdown: string): string {\n if (!markdown) return '';\n\n const trimmed = markdown.trim();\n if (trimmed === '') return '';\n\n let result = trimmed;\n\n // 0. Fix broken headings where ## is on its own line followed by the text\n // Pattern: \"## \\n\\nSome text\" → \"## Some text\"\n result = result.replace(/^(#{1,6})\\s*\\n\\n+(\\S[^\\n]*)/gm, '$1 $2');\n\n // 0.5. Normalize multiple spaces after heading markers to single space\n // Pattern: \"## Subtitle\" → \"## Subtitle\"\n result = result.replace(/(#{1,6})\\s{2,}/g, '$1 ');\n\n // 1. Fix navigation links with excessive whitespace\n result = result.replace(/\\*\\s+\\[\\s*([^\\n]+?)\\s*\\]\\(([^)]+)\\)/g, '* [$1]($2)');\n\n // 2. Handle headings with specific newline requirements\n\n // Text followed by heading should have a single newline between them (no blank line)\n result = result.replace(/([^\\n])\\n\\n+(#\\s)/g, '$1\\n$2');\n\n // Add double newlines between text and next heading\n result = result.replace(/(Some text\\.)\\n(##\\s)/g, '$1\\n\\n$2');\n\n // Double newlines after a heading when followed by text\n result = result.replace(/(#{1,6}\\s[^\\n]+)\\n([^#\\n])/g, '$1\\n\\n$2');\n\n // Double newlines between headings\n result = result.replace(/(#{1,6}\\s[^\\n]+)\\n(#{1,6}\\s)/g, '$1\\n\\n$2');\n\n // 3. Lists - ensure all list items have single newlines only\n result = result.replace(/(\\* Item 1)\\n\\n+(\\* Item 2)\\n\\n+(\\* Item 3)/g, '$1\\n$2\\n$3');\n\n // 3.5. General list item spacing - ensure single newlines between list items\n result = result.replace(/(^\\*\\s[^\\n]+)\\n{2,}(^\\*\\s)/gm, '$1\\n$2');\n\n // 4. Clean up excessive blank lines (3+ newlines → 2 newlines)\n result = result.replace(/\\n{3,}/g, '\\n\\n');\n\n // 5. Code blocks - no blank lines after opening or before closing backticks\n result = result.replace(/(```[^\\n]*)\\n\\n+/g, '$1\\n');\n result = result.replace(/\\n\\n+```/g, '\\n```');\n\n // 6. Remove empty list items\n result = result.replace(/\\*\\s*\\n\\s*\\*/g, '*');\n\n // 7. Strip any remaining HTML tags that leaked through (common in MkDocs/Material)\n // Remove table structure tags\n result = result.replace(/<\\/?table[^>]*>/gi, '');\n result = result.replace(/<\\/?tbody[^>]*>/gi, '');\n result = result.replace(/<\\/?thead[^>]*>/gi, '');\n result = result.replace(/<\\/?tr[^>]*>/gi, '');\n result = result.replace(/<\\/?td[^>]*>/gi, '');\n result = result.replace(/<\\/?th[^>]*>/gi, '');\n\n // Remove empty anchor tags: <a></a> or <a id=\"...\"></a>\n result = result.replace(/<a[^>]*><\\/a>/gi, '');\n\n // Remove span tags (syntax highlighting remnants)\n result = result.replace(/<\\/?span[^>]*>/gi, '');\n\n // Remove div tags\n result = result.replace(/<\\/?div[^>]*>/gi, '');\n\n // Remove pre/code tags that leaked\n result = result.replace(/<\\/?pre[^>]*>/gi, '');\n result = result.replace(/<\\/?code[^>]*>/gi, '');\n\n // 8. Remove empty markdown links: [](url) and []()\n result = result.replace(/\\[\\]\\([^)]*\\)/g, '');\n\n // 9. Remove codelineno references that leaked into content\n // Pattern: [](_file.md#__codelineno-N-M)\n result = result.replace(/\\[\\]\\([^)]*#__codelineno-[^)]+\\)/g, '');\n\n // Also clean inline codelineno patterns\n result = result.replace(/\\[?\\]?\\([^)]*#__codelineno-[^)]*\\)/g, '');\n\n // 10. Clean up any double-escaped HTML entities that might result\n result = result.replace(/&amp;lt;/g, '&lt;');\n result = result.replace(/&amp;gt;/g, '&gt;');\n result = result.replace(/&amp;amp;/g, '&amp;');\n\n // 11. Final cleanup - normalize excessive whitespace from removed tags\n result = result.replace(/\\n{3,}/g, '\\n\\n');\n result = result.replace(/[ \\t]+\\n/g, '\\n');\n\n return result;\n}\n","/**\n * Claude CLI client for intelligent crawling and extraction\n * Uses `claude -p` programmatically to analyze page structure and extract content\n */\n\nimport { spawn, execSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\n/**\n * Schema for crawl strategy response from Claude\n */\nexport interface CrawlStrategy {\n urls: string[];\n reasoning: string;\n}\n\nconst CRAWL_STRATEGY_SCHEMA = {\n type: 'object',\n properties: {\n urls: {\n type: 'array',\n items: { type: 'string' },\n description: 'List of URLs to crawl based on the instruction',\n },\n reasoning: {\n type: 'string',\n description: 'Brief explanation of why these URLs were selected',\n },\n },\n required: ['urls', 'reasoning'],\n};\n\n/**\n * Client for interacting with Claude Code CLI\n */\nexport class ClaudeClient {\n private readonly timeout: number;\n private static availabilityChecked = false;\n private static available = false;\n private static claudePath: string | null = null;\n\n /**\n * Get the path to the Claude CLI binary\n * Checks in order:\n * 1. CLAUDE_BIN environment variable (explicit override)\n * 2. ~/.claude/local/claude (newer installation location)\n * 3. ~/.local/bin/claude (standard installation location)\n * 4. 'claude' in PATH (custom installations)\n */\n static getClaudePath(): string | null {\n // Check environment variable override\n const envPath = process.env['CLAUDE_BIN'];\n if (envPath !== undefined && envPath !== '' && existsSync(envPath)) {\n return envPath;\n }\n\n // Check ~/.claude/local/claude (newer location)\n const claudeLocalPath = join(homedir(), '.claude', 'local', 'claude');\n if (existsSync(claudeLocalPath)) {\n return claudeLocalPath;\n }\n\n // Check ~/.local/bin/claude (standard location)\n const localBinPath = join(homedir(), '.local', 'bin', 'claude');\n if (existsSync(localBinPath)) {\n return localBinPath;\n }\n\n // Check if 'claude' is in PATH (custom installations, uses 'command -v' which handles aliases)\n try {\n const result = execSync('command -v claude', { stdio: ['pipe', 'pipe', 'ignore'] });\n const path = result.toString().trim();\n if (path) {\n return path;\n }\n } catch {\n // Not in PATH\n }\n\n return null;\n }\n\n /**\n * Check if Claude CLI is available\n * Result is cached after first check for performance\n */\n static isAvailable(): boolean {\n if (!ClaudeClient.availabilityChecked) {\n ClaudeClient.claudePath = ClaudeClient.getClaudePath();\n ClaudeClient.available = ClaudeClient.claudePath !== null;\n ClaudeClient.availabilityChecked = true;\n }\n return ClaudeClient.available;\n }\n\n /**\n * Get the cached Claude path (call isAvailable first)\n */\n static getCachedPath(): string | null {\n return ClaudeClient.claudePath;\n }\n\n /**\n * Reset availability cache (for testing)\n */\n static resetAvailabilityCache(): void {\n ClaudeClient.availabilityChecked = false;\n ClaudeClient.available = false;\n }\n\n constructor(options: { timeout?: number } = {}) {\n this.timeout = options.timeout ?? 30000; // 30s default\n }\n\n /**\n * Determine which URLs to crawl based on natural language instruction\n *\n * @param seedUrl - The URL of the seed page (for resolving relative URLs)\n * @param seedHtml - HTML content of the seed page\n * @param instruction - Natural language crawl instruction (e.g., \"scrape all Getting Started pages\")\n * @returns List of URLs to crawl with reasoning\n */\n async determineCrawlUrls(\n seedUrl: string,\n seedHtml: string,\n instruction: string\n ): Promise<CrawlStrategy> {\n const prompt = `You are analyzing a webpage to determine which pages to crawl based on the user's instruction.\n\nBase URL: ${seedUrl}\n\nInstruction: ${instruction}\n\nWebpage HTML (analyze the navigation structure, links, and content):\n${this.truncateHtml(seedHtml, 50000)}\n\nBased on the instruction, extract and return a list of absolute URLs that should be crawled. When you encounter relative URLs (starting with \"/\" or without a protocol), resolve them against the Base URL. For example, if Base URL is \"https://example.com/docs\" and you see href=\"/docs/hooks\", return \"https://example.com/docs/hooks\".\n\nLook for navigation menus, sidebars, headers, and link structures that match the instruction.\n\nReturn only URLs that are relevant to the instruction. If the instruction mentions specific sections (e.g., \"Getting Started\"), find links in those sections.`;\n\n try {\n const result = await this.callClaude(prompt, CRAWL_STRATEGY_SCHEMA);\n const rawParsed: unknown = JSON.parse(result);\n\n // Claude CLI with --json-schema returns wrapper: {type, result, structured_output: {...}}\n // Extract structured_output if present, otherwise use raw response\n const parsed = this.extractStructuredOutput(rawParsed);\n\n // Validate and narrow type\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('urls' in parsed) ||\n !('reasoning' in parsed) ||\n !Array.isArray(parsed.urls) ||\n parsed.urls.length === 0 ||\n typeof parsed.reasoning !== 'string' ||\n !parsed.urls.every((url) => typeof url === 'string')\n ) {\n throw new Error('Claude returned invalid crawl strategy');\n }\n\n // Type is now properly narrowed - urls is string[] after validation\n return { urls: parsed.urls, reasoning: parsed.reasoning };\n } catch (error) {\n throw new Error(\n `Failed to determine crawl strategy: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n /**\n * Extract specific information from markdown content using natural language\n *\n * @param markdown - Page content in markdown format\n * @param instruction - Natural language extraction instruction (e.g., \"extract pricing info\")\n * @returns Extracted information as text\n */\n async extractContent(markdown: string, instruction: string): Promise<string> {\n const prompt = `${instruction}\n\nContent to analyze:\n${this.truncateMarkdown(markdown, 100000)}`;\n\n try {\n const result = await this.callClaude(prompt);\n return result.trim();\n } catch (error) {\n throw new Error(\n `Failed to extract content: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n /**\n * Call Claude CLI with a prompt\n *\n * @param prompt - The prompt to send to Claude\n * @param jsonSchema - Optional JSON schema for structured output\n * @returns Claude's response as a string\n */\n private async callClaude(prompt: string, jsonSchema?: Record<string, unknown>): Promise<string> {\n return new Promise<string>((resolve, reject) => {\n // Ensure we have Claude path\n const claudePath = ClaudeClient.getCachedPath();\n if (claudePath === null) {\n reject(new Error('Claude CLI not available'));\n return;\n }\n\n const args = ['-p'];\n\n // Add JSON schema if provided\n if (jsonSchema) {\n args.push('--json-schema', JSON.stringify(jsonSchema));\n args.push('--output-format', 'json');\n }\n\n // Strip CLAUDE-prefixed env vars to avoid nested session detection\n const cleanEnv = Object.fromEntries(\n Object.entries(process.env).filter(([key]) => !key.startsWith('CLAUDE'))\n );\n\n const proc = spawn(claudePath, args, {\n stdio: ['pipe', 'pipe', 'pipe'],\n cwd: process.cwd(),\n env: cleanEnv,\n });\n\n let stdout = '';\n let stderr = '';\n let timeoutId: NodeJS.Timeout | undefined;\n\n // Set timeout\n if (this.timeout > 0) {\n timeoutId = setTimeout(() => {\n proc.kill('SIGTERM');\n reject(new Error(`Claude CLI timed out after ${String(this.timeout)}ms`));\n }, this.timeout);\n }\n\n proc.stdout.on('data', (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n\n proc.stderr.on('data', (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n proc.on('close', (code: number | null) => {\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId);\n }\n\n if (code === 0) {\n resolve(stdout.trim());\n } else {\n reject(\n new Error(`Claude CLI exited with code ${String(code)}${stderr ? `: ${stderr}` : ''}`)\n );\n }\n });\n\n proc.on('error', (err) => {\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId);\n }\n reject(new Error(`Failed to spawn Claude CLI: ${err.message}`));\n });\n\n // Write prompt to stdin\n proc.stdin.write(prompt);\n proc.stdin.end();\n });\n }\n\n /**\n * Truncate HTML to a maximum length (keep important parts)\n */\n private truncateHtml(html: string, maxLength: number): string {\n if (html.length <= maxLength) return html;\n\n // Try to keep the beginning (usually has navigation)\n return `${html.substring(0, maxLength)}\\n\\n[... HTML truncated ...]`;\n }\n\n /**\n * Truncate markdown to a maximum length\n */\n private truncateMarkdown(markdown: string, maxLength: number): string {\n if (markdown.length <= maxLength) return markdown;\n\n return `${markdown.substring(0, maxLength)}\\n\\n[... content truncated ...]`;\n }\n\n /**\n * Type guard to check if value is a record (plain object)\n */\n private isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n }\n\n /**\n * Extract structured_output from Claude CLI wrapper format if present.\n * Claude CLI with --json-schema returns: {type, result, structured_output: {...}}\n * This method extracts the inner structured_output, or returns the raw value if not wrapped.\n */\n private extractStructuredOutput(rawParsed: unknown): unknown {\n if (this.isRecord(rawParsed) && 'structured_output' in rawParsed) {\n const structuredOutput = rawParsed['structured_output'];\n if (typeof structuredOutput === 'object') {\n return structuredOutput;\n }\n }\n return rawParsed;\n }\n}\n","/**\n * Node.js Playwright-based web crawler\n * Replaces crawl4ai Python dependency for headless browser crawling\n */\n\nimport { chromium, type Browser, type Page } from 'playwright';\nimport { createLogger, summarizePayload } from '../logging/index.js';\n\nconst logger = createLogger('playwright-crawler');\n\n// Default timeout for page navigation\nconst DEFAULT_TIMEOUT = 30000;\n\n// User agent to identify our crawler\nconst USER_AGENT =\n 'Mozilla/5.0 (compatible; BlueraKnowledge/1.0; +https://github.com/blueraai/bluera-knowledge)';\n\nexport interface PlaywrightFetchResult {\n html: string;\n links: string[];\n}\n\n/**\n * Singleton browser instance for connection reuse\n */\nlet browserInstance: Browser | null = null;\n\n/**\n * Get or create the browser instance\n */\nasync function getBrowser(): Promise<Browser> {\n if (browserInstance?.isConnected() !== true) {\n logger.debug('Launching new Chromium browser instance');\n browserInstance = await chromium.launch({\n headless: true,\n });\n }\n return browserInstance;\n}\n\n/**\n * Close the browser instance (call on cleanup)\n */\nexport async function closeBrowser(): Promise<void> {\n if (browserInstance?.isConnected() === true) {\n await browserInstance.close();\n browserInstance = null;\n }\n}\n\n/**\n * Fetch a URL using Playwright headless browser\n * Returns the rendered HTML and discovered links\n */\nexport async function fetchWithPlaywright(\n url: string,\n timeoutMs: number = DEFAULT_TIMEOUT\n): Promise<PlaywrightFetchResult> {\n const startTime = Date.now();\n logger.debug({ url, timeoutMs }, 'Fetching page with Playwright');\n\n const browser = await getBrowser();\n const context = await browser.newContext({\n userAgent: USER_AGENT,\n });\n\n let page: Page | null = null;\n\n try {\n page = await context.newPage();\n\n // Navigate to the URL and wait for network idle\n await page.goto(url, {\n waitUntil: 'networkidle',\n timeout: timeoutMs,\n });\n\n // Get the full rendered HTML\n const html = await page.content();\n\n // Extract all links from the page\n const links = await page.evaluate(() => {\n const anchors = document.querySelectorAll('a[href]');\n const hrefs: string[] = [];\n\n anchors.forEach((anchor) => {\n const href = anchor.getAttribute('href');\n if (href !== null && href !== '') {\n try {\n // Resolve relative URLs to absolute\n const absoluteUrl = new URL(href, document.baseURI).href;\n // Only include http/https links\n if (absoluteUrl.startsWith('http://') || absoluteUrl.startsWith('https://')) {\n hrefs.push(absoluteUrl);\n }\n } catch {\n // Skip invalid URLs\n }\n }\n });\n\n // Deduplicate\n return [...new Set(hrefs)];\n });\n\n const durationMs = Date.now() - startTime;\n logger.info(\n {\n url,\n durationMs,\n linkCount: links.length,\n ...summarizePayload(html, 'raw-html', url),\n },\n 'Page fetched with Playwright'\n );\n\n return { html, links };\n } finally {\n // Clean up the context (closes the page too)\n await context.close();\n }\n}\n\n/**\n * Check if Playwright browsers are installed\n * Fast check without launching a browser\n */\nexport function isPlaywrightInstalled(): boolean {\n try {\n // Try to get the chromium executable path\n // This will throw if browsers aren't installed\n const execPath = chromium.executablePath();\n logger.debug({ execPath }, 'Playwright Chromium executable found');\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"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","names":["logger","logger","DEFAULT_TIMEOUT"]}
@@ -357,7 +357,6 @@ var JobDetailsSchema = z.object({
357
357
  crawlInstruction: z.string().optional(),
358
358
  extractInstruction: z.string().optional(),
359
359
  maxPages: z.number().optional(),
360
- simple: z.boolean().optional(),
361
360
  useHeadless: z.boolean().optional(),
362
361
  pagesCrawled: z.number().optional(),
363
362
  // Phase tracking
@@ -4952,6 +4951,32 @@ function mapSearchIntentToQueryIntent(intent) {
4952
4951
  return "how-to";
4953
4952
  }
4954
4953
  }
4954
+ var INTENT_EXPANSION_TERMS = {
4955
+ "find-implementation": "source code implementation function class",
4956
+ "find-documentation": "documentation guide tutorial example",
4957
+ "find-usage": "usage example how to use",
4958
+ "find-pattern": "pattern matching code structure",
4959
+ "find-definition": "definition type interface declaration",
4960
+ "find-files": "file module path"
4961
+ };
4962
+ function expandQueryWithIntent(query, intent) {
4963
+ if (intent === void 0) return query;
4964
+ const expansion = INTENT_EXPANSION_TERMS[intent];
4965
+ return `${query} ${expansion}`;
4966
+ }
4967
+ function isStrongFtsSignal(query, ftsResults) {
4968
+ if (ftsResults.length < 2) return false;
4969
+ const top = ftsResults[0];
4970
+ const second = ftsResults[1];
4971
+ if (top === void 0 || second === void 0) return false;
4972
+ if (second.score > 0 && top.score / second.score <= 2) return false;
4973
+ const queryLower = query.toLowerCase();
4974
+ const rawFile = top.metadata["file"] ?? top.metadata["path"];
4975
+ const rawName = top.metadata["name"];
4976
+ const filePath = typeof rawFile === "string" ? rawFile : "";
4977
+ const name = typeof rawName === "string" ? rawName : "";
4978
+ return filePath.toLowerCase().includes(queryLower) || name.toLowerCase().includes(queryLower);
4979
+ }
4955
4980
  var RRF_PRESETS = {
4956
4981
  code: { k: 25, vectorWeight: 0.75, ftsWeight: 0.25 },
4957
4982
  web: { k: 30, vectorWeight: 0.7, ftsWeight: 0.3 }
@@ -5040,7 +5065,8 @@ var SearchService = class {
5040
5065
  let rerankTimeMs;
5041
5066
  const fetchLimit = limit * 3;
5042
5067
  if (mode === "vector") {
5043
- const rawResults = await this.vectorSearchRaw(query.query, stores, fetchLimit);
5068
+ const expandedQuery = expandQueryWithIntent(query.query, query.intent);
5069
+ const rawResults = await this.vectorSearchRaw(expandedQuery, stores, fetchLimit);
5044
5070
  maxRawScore = rawResults.length > 0 ? rawResults[0]?.score ?? 0 : 0;
5045
5071
  allResults = this.normalizeAndFilterScores(rawResults, query.threshold).slice(0, fetchLimit);
5046
5072
  } else if (mode === "fts") {
@@ -5050,7 +5076,8 @@ var SearchService = class {
5050
5076
  query.query,
5051
5077
  stores,
5052
5078
  fetchLimit,
5053
- query.threshold
5079
+ query.threshold,
5080
+ query.intent
5054
5081
  );
5055
5082
  allResults = hybridResult.results;
5056
5083
  maxRawScore = hybridResult.maxRawScore;
@@ -5231,18 +5258,37 @@ var SearchService = class {
5231
5258
  /**
5232
5259
  * Internal hybrid search result with additional metadata for confidence calculation.
5233
5260
  */
5234
- async hybridSearchWithMetadata(query, stores, limit, threshold) {
5261
+ async hybridSearchWithMetadata(query, stores, limit, threshold, searchIntent) {
5235
5262
  const intents = classifyQueryIntents(query);
5236
5263
  const envOverrides = parseSearchEnvOverrides(false);
5237
5264
  const candidateMultiplier = envOverrides.candidateMultiplier ?? DEFAULT_CANDIDATE_MULTIPLIER;
5238
- const rawVectorResults = await this.vectorSearchRaw(query, stores, limit * candidateMultiplier);
5265
+ const ftsResults = await this.ftsSearch(query, stores, limit * candidateMultiplier);
5266
+ if (isStrongFtsSignal(query, ftsResults)) {
5267
+ logger4.debug(
5268
+ { query, topScore: ftsResults[0]?.score },
5269
+ "Strong FTS signal \u2014 skipping vector search"
5270
+ );
5271
+ const sorted2 = ftsResults.slice(0, limit).map((r, i) => ({
5272
+ ...r,
5273
+ score: Math.round((1 - i / Math.max(ftsResults.length, 1)) * 1e6) / 1e6
5274
+ }));
5275
+ if (threshold !== void 0) {
5276
+ return { results: sorted2.filter((r) => r.score >= threshold), maxRawScore: 0 };
5277
+ }
5278
+ return { results: sorted2, maxRawScore: 0 };
5279
+ }
5280
+ const expandedQuery = expandQueryWithIntent(query, searchIntent);
5281
+ const rawVectorResults = await this.vectorSearchRaw(
5282
+ expandedQuery,
5283
+ stores,
5284
+ limit * candidateMultiplier
5285
+ );
5239
5286
  const rawVectorScores = /* @__PURE__ */ new Map();
5240
5287
  rawVectorResults.forEach((r) => {
5241
5288
  rawVectorScores.set(r.id, r.score);
5242
5289
  });
5243
5290
  const maxRawScore = rawVectorResults.length > 0 ? rawVectorResults[0]?.score ?? 0 : 0;
5244
5291
  const vectorResults = this.normalizeAndFilterScores(rawVectorResults);
5245
- const ftsResults = await this.ftsSearch(query, stores, limit * candidateMultiplier);
5246
5292
  const vectorRanks = /* @__PURE__ */ new Map();
5247
5293
  const ftsRanks = /* @__PURE__ */ new Map();
5248
5294
  const allDocs = /* @__PURE__ */ new Map();
@@ -5320,10 +5366,20 @@ var SearchService = class {
5320
5366
  reranked.results.forEach((r) => {
5321
5367
  rerankedScores.set(r.id, r.rerankerScore);
5322
5368
  });
5323
- sorted = sortedAll.map((r) => ({
5324
- ...r,
5325
- rerankerScore: rerankedScores.get(r.id)
5326
- })).sort((a, b) => (b.rerankerScore ?? -Infinity) - (a.rerankerScore ?? -Infinity)).slice(0, limit);
5369
+ const maxRrfScore = sortedAll[0]?.score ?? 1;
5370
+ sorted = sortedAll.map((r, rrfRank) => {
5371
+ const rerankerScore = rerankedScores.get(r.id);
5372
+ if (rerankerScore === void 0) {
5373
+ return { ...r, blendedScore: -Infinity };
5374
+ }
5375
+ const normalizedRrf = maxRrfScore > 0 ? r.score / maxRrfScore : 0;
5376
+ const rrfWeight = rrfRank < 3 ? 0.7 : rrfRank < 10 ? 0.5 : 0.3;
5377
+ const rerankerWeight = 1 - rrfWeight;
5378
+ return {
5379
+ ...r,
5380
+ blendedScore: normalizedRrf * rrfWeight + rerankerScore * rerankerWeight
5381
+ };
5382
+ }).sort((a, b) => b.blendedScore - a.blendedScore).slice(0, limit);
5327
5383
  } else {
5328
5384
  sorted = sortedAll.slice(0, limit);
5329
5385
  }
@@ -7576,4 +7632,4 @@ export {
7576
7632
  createServices,
7577
7633
  destroyServices
7578
7634
  };
7579
- //# sourceMappingURL=chunk-3TB7TDVF.js.map
7635
+ //# sourceMappingURL=chunk-FYHKBCIH.js.map