import { load } from "cheerio"; import { Type } from "typebox"; import { HttpError, HttpStatusError } from "../shared/http"; import { isFetchEnabled } from "../shared/env"; import { chunkAndSerializeResponse, serializeResponseWithMeta } from "../shared/serialize"; import { chunkingManager } from "../shared/instances"; import { extractSections, htmlToMarkdown, prioritizeSections } from "../shared/content"; import { BaseProvider, type HttpClientFactory, type ProviderMetadata, type ProviderResult, type ToolDefinition, } from "./base"; const BASE_URL = "https://library.humio.com/data-analysis/"; const SYNTAX_TOPICS: Record = { comments: { page: "syntax-comments.html", description: "Single-line and multi-line comment syntax", }, filters: { page: "syntax-filters.html", description: "Query filters and field-based filtering", }, operators: { page: "syntax-operators.html", description: "Logical and comparison operators", }, fields: { page: "syntax-fields.html", description: "Field creation, assignment, and manipulation", }, "user-input": { page: "syntax-fields-user-input.html", description: "User-configurable parameters in queries", }, conditional: { page: "syntax-conditional.html", description: "Conditional evaluation with case/match statements", }, array: { page: "syntax-array.html", description: "Array processing and indexing", }, expressions: { page: "syntax-expressions.html", description: "Expression syntax and evaluation", }, "user-functions": { page: "syntax-function-user.html", description: "User-defined functions", }, "function-calls": { page: "syntax-function.html", description: "Function call syntax", }, time: { page: "syntax-time.html", description: "Time-related syntax overview", }, timezones: { page: "syntax-time-timezones.html", description: "Timezone handling in queries", }, "relative-time": { page: "syntax-time-relative.html", description: "Relative time expressions", }, macros: { page: "syntax-macros.html", description: "Query macros and reusable components", }, regex: { page: "syntax-regex.html", description: "Regular expression overview", }, "regex-syntax": { page: "syntax-regex-syntax.html", description: "Regular expression syntax reference", }, "regex-flags": { page: "syntax-regex-flags.html", description: "Regular expression flags and modifiers", }, "regex-engines": { page: "syntax-regex-engines.html", description: "Regular expression engine options", }, }; const FUNCTION_CATEGORIES: Record = { aggregate: { page: "functions-aggregate.html", description: "Aggregation functions (count, sum, avg, etc.)", }, array: { page: "functions-array.html", description: "Array manipulation functions", }, comparison: { page: "functions-comparison.html", description: "Comparison and equality functions", }, conditional: { page: "functions-condition.html", description: "Conditional logic functions", }, "data-manipulation": { page: "functions-data-manipulation.html", description: "Data transformation functions", }, event: { page: "functions-event.html", description: "Event information functions", }, filter: { page: "functions-filter.html", description: "Filtering functions", }, formatting: { page: "functions-formatting.html", description: "Output formatting functions", }, geolocation: { page: "functions-geolocation.html", description: "Geographic and IP location functions", }, hash: { page: "functions-hash-functions.html", description: "Hashing functions (MD5, SHA, etc.)", }, join: { page: "functions-join-functions.html", description: "Data joining functions", }, math: { page: "functions-math.html", description: "Mathematical functions", }, network: { page: "functions-network-location.html", description: "Network and location functions", }, parsing: { page: "functions-parsing.html", description: "Data parsing functions", }, regex: { page: "functions-regular-expression.html", description: "Regular expression functions", }, security: { page: "functions-security.html", description: "Security-related functions", }, statistics: { page: "functions-statistics.html", description: "Statistical functions", }, string: { page: "functions-string.html", description: "String manipulation functions", }, "time-date": { page: "functions-time-date.html", description: "Time and date functions", }, widget: { page: "functions-widget.html", description: "Dashboard widget functions", }, }; const FUNCTION_CATEGORY_PAGES = new Set(Object.values(FUNCTION_CATEGORIES).map((category) => category.page)); function toUtf8Bytes(value: string): number { return Buffer.byteLength(value, "utf8"); } function joinUrl(path: string): string { return new URL(path, BASE_URL).toString(); } function byteTruncateUtf8(text: string, maxBytes: number): string { if (!text) { return ""; } const encoded = Buffer.from(text, "utf8"); if (encoded.length <= maxBytes) { return text; } let cut = Math.max(0, maxBytes); const decoder = new TextDecoder("utf-8", { fatal: true }); while (cut > 0) { try { return decoder.decode(encoded.subarray(0, cut)); } catch { cut -= 1; } } return ""; } function getTagName(node: unknown): string { return String((node as { tagName?: string } | undefined)?.tagName ?? "").toLowerCase(); } function getTextSelection($node: ReturnType, selector: string): string { const el = $node(selector).first(); return el.length > 0 ? el.text().trim() : ""; } export class LogScaleProvider extends BaseProvider { public constructor(httpClientFactory: HttpClientFactory) { super(httpClientFactory); } getMetadata(): ProviderMetadata { const toolNames = ["search_logscale_docs", "list_logscale_functions"]; if (isFetchEnabled()) { toolNames.push("logscale_syntax", "logscale_function"); } return { name: "logscale", description: "LogScale (Humio) query language syntax and function documentation", exposeAsTool: true, toolNames, supportsLibrarySearch: false, requiredEnvVars: [], optionalEnvVars: [], toolTiers: { search_logscale_docs: { tier: 5, deferRecommended: true, category: "search", }, list_logscale_functions: { tier: 5, deferRecommended: true, category: "metadata", }, logscale_syntax: { tier: 5, deferRecommended: true, category: "fetch", }, logscale_function: { tier: 5, deferRecommended: true, category: "fetch", }, }, }; } async searchLibrary(_library: string, _limit = 5): Promise { return { success: false, error: "LogScale provider does not support library search", providerName: "logscale", }; } protected async _fetchPage(url: string): Promise> { const client = await this.httpClient(); const response = (await client.get(url, { headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0 Safari/537.36" } })) as { text: string; raiseForStatus(): void; }; response.raiseForStatus(); return load(response.text); } async _searchDocs(query: string, limit = 10): Promise> { const queryLower = query.toLowerCase(); const queryWords = queryLower.split(/\s+/).filter(Boolean); const results: Array> = []; for (const [topicKey, topicInfo] of Object.entries(SYNTAX_TOPICS)) { let score = 0; for (const word of queryWords) { if (topicKey.includes(word)) { score += 3; } if (topicInfo.description.toLowerCase().includes(word)) { score += 1; } } if (score > 0) { results.push({ type: "syntax", name: topicKey, description: topicInfo.description, url: joinUrl(topicInfo.page), score, }); } } for (const [categoryKey, categoryInfo] of Object.entries(FUNCTION_CATEGORIES)) { let score = 0; for (const word of queryWords) { if (categoryKey.includes(word)) { score += 3; } if (categoryInfo.description.toLowerCase().includes(word)) { score += 1; } } if (score > 0) { results.push({ type: "function_category", name: categoryKey, description: categoryInfo.description, url: joinUrl(categoryInfo.page), score, }); } } try { const functionsUrl = joinUrl("functions.html"); const soup = await this._fetchPage(functionsUrl); for (const link of soup("a[href]").toArray()) { const el = soup(link); const href = String(el.attr("href") ?? ""); if (!href.startsWith("functions-") || !href.endsWith(".html")) { continue; } if (FUNCTION_CATEGORY_PAGES.has(href)) { continue; } let funcName = el.text().trim(); if (!funcName) { continue; } if (funcName === href) { funcName = href.replace("functions-", "").replace(".html", ""); } let score = 0; const funcLower = funcName.toLowerCase(); const hrefLower = href.toLowerCase(); for (const word of queryWords) { if (funcLower.includes(word)) { score += 5; } if (hrefLower.includes(word)) { score += 2; } } if (score > 0) { results.push({ type: "function", name: funcName, description: `LogScale function: ${funcName}`, url: joinUrl(href), score, }); } } } catch { // Continue without function search if it fails. } results.sort((left, right) => Number(right.score ?? 0) - Number(left.score ?? 0)); const seenUrls = new Set(); const uniqueResults: Array> = []; for (const result of results) { const url = String(result.url ?? ""); if (!url || seenUrls.has(url)) { continue; } seenUrls.add(url); uniqueResults.push({ type: result.type, name: result.name, description: result.description, url: result.url, }); if (uniqueResults.length >= limit) { break; } } return { query, results: uniqueResults, total_found: results.length, source: BASE_URL, }; } async _fetchSyntaxDocs(topic: string, maxBytes = 20480): Promise> { const topicLower = topic.toLowerCase().trim(); let matchedTopic: string | null = null; let matchedInfo: { page: string; description: string } | null = null; if (topicLower in SYNTAX_TOPICS) { matchedTopic = topicLower; matchedInfo = SYNTAX_TOPICS[topicLower]; } else { for (const [key, info] of Object.entries(SYNTAX_TOPICS)) { if (topicLower.includes(key) || key.includes(topicLower)) { matchedTopic = key; matchedInfo = info; break; } if (info.description.toLowerCase().includes(topicLower)) { matchedTopic = key; matchedInfo = info; break; } } } if (!matchedInfo || !matchedTopic) { return { topic, error: `Topic not found. Available topics: ${Object.keys(SYNTAX_TOPICS).sort().join(", ")}`, source: BASE_URL, }; } try { const url = joinUrl(matchedInfo.page); const soup = await this._fetchPage(url); const content = this._extractMainContent(soup, url, maxBytes); return { topic: matchedTopic, description: matchedInfo.description, content, size_bytes: toUtf8Bytes(content), url, source: "logscale_docs", }; } catch (error) { if (error instanceof HttpStatusError) { return { topic, error: `HTTP error ${error.response.status}`, source: BASE_URL, }; } if (error instanceof HttpError) { return { topic, error: `Failed to fetch docs: ${error}`, source: BASE_URL, }; } return { topic, error: `Error processing docs: ${error instanceof Error ? error.message : String(error)}`, source: BASE_URL, }; } } async _fetchFunctionDocs(functionName: string, maxBytes = 20480): Promise> { const funcLower = functionName.toLowerCase().trim(); const funcSlug = funcLower.replace(/:/g, "-").replace(/\(\)/g, "").replace(/\s+/g, "-"); const funcUrl = joinUrl(`functions-${funcSlug}.html`); try { const soup = await this._fetchPage(funcUrl); const content = this._extractMainContent(soup, funcUrl, maxBytes); let signature = ""; const sigElem = soup("code.code-highlight").first(); if (sigElem.length > 0) { signature = sigElem.text().trim(); } return { function: functionName, signature, content, size_bytes: toUtf8Bytes(content), url: funcUrl, source: "logscale_docs", }; } catch (error) { if (error instanceof HttpStatusError) { if (error.response.status === 404) { const searchResult = await this._searchDocs(functionName, 5); const funcMatches = Array.isArray(searchResult.results) ? searchResult.results.filter((result) => result && typeof result === "object" && result.type === "function") : []; if (funcMatches.length > 0) { const suggestions = funcMatches.slice(0, 3); return { function: functionName, error: `Function not found. Did you mean: ${suggestions.map((match) => String(match.name ?? "")).join(", ")}?`, suggestions, source: BASE_URL, }; } return { function: functionName, error: "Function not found", source: BASE_URL, }; } return { function: functionName, error: `HTTP error ${error.response.status}`, source: BASE_URL, }; } if (error instanceof HttpError) { return { function: functionName, error: `Failed to fetch docs: ${error}`, source: BASE_URL, }; } return { function: functionName, error: `Error processing docs: ${error instanceof Error ? error.message : String(error)}`, source: BASE_URL, }; } } protected _extractMainContent(soup: ReturnType, baseUrl: string, maxBytes: number): string { const $ = soup; $("nav, aside, footer, header, script, style, noscript").remove(); const unwantedPatterns = [ "nav", "sidebar", "menu", "breadcrumb", "toc", "footer", "header", "skip", "social", "share", "cookie", "banner", ]; const removable: any[] = []; $("*[class], *[id]").each((_index, element) => { const classValue = String($(element).attr("class") ?? "").toLowerCase(); const idValue = String($(element).attr("id") ?? "").toLowerCase(); for (const pattern of unwantedPatterns) { if ((classValue && classValue.includes(pattern)) || (idValue && idValue.includes(pattern))) { removable.push(element); break; } } }); for (const element of removable) { $(element as any).remove(); } let mainContent = $("main").first(); if (!mainContent.length) { mainContent = $("article").first(); } if (!mainContent.length) { for (const className of ["content", "doc-content", "documentation", "page-content"]) { mainContent = $(`div.${className}`).first(); if (mainContent.length) { break; } } } if (!mainContent.length) { const heading = $("h1, h2").first(); if (heading.length) { let parent = heading.parent(); while (parent.length && !["body", "html"].includes(getTagName(parent.get(0)))) { if (parent.text().trim().length > 500) { mainContent = parent; break; } parent = parent.parent(); } } } if (!mainContent.length) { mainContent = $("body").first(); } if (!mainContent.length) { return "Unable to extract content from page"; } const contentParts: string[] = []; const titleElem = mainContent.find("h1, h2").first(); const titleNode = titleElem.length > 0 ? titleElem.get(0) : null; if (titleElem.length > 0) { contentParts.push(`## ${titleElem.text().trim()}\n`); } for (const element of mainContent.find("h1, h2, h3, h4, h5, h6, p, table, pre, code, ul, ol, dl").toArray()) { if (titleNode && element === titleNode) { continue; } const elem = $(element); const text = elem.text().trim(); if (!text) { continue; } if (["ul", "ol"].includes(getTagName(element))) { const items = elem.find("li").toArray(); if (items.length > 0) { const linkCount = items.reduce((count, li) => count + ($(li).find("a").length > 0 ? 1 : 0), 0); const avgLen = items.reduce((sum, li) => sum + $(li).text().trim().length, 0) / items.length; if (linkCount / items.length > 0.8 && avgLen < 50) { continue; } } } const elemMd = htmlToMarkdown($.html(element) ?? "", baseUrl); if (elemMd.trim()) { contentParts.push(elemMd); } } let markdownContent = contentParts.join("\n\n"); if (markdownContent.length < 200) { markdownContent = htmlToMarkdown($.html(mainContent) ?? "", baseUrl); } const sections = extractSections(markdownContent); if (sections.length > 0) { return prioritizeSections(sections, maxBytes); } if (toUtf8Bytes(markdownContent) > maxBytes) { return byteTruncateUtf8(markdownContent, maxBytes); } return markdownContent; } getTools(): Record { const searchLogscaleDocs: ToolDefinition = { name: "search_logscale_docs", label: "Search LogScale docs", description: "Search LogScale syntax, functions, and categories.", promptSnippet: 'search_logscale_docs("match")', parameters: Type.Object({ query: Type.String({ description: "Search query" }), limit: Type.Optional(Type.Integer({ description: "Maximum results to return" })), }), execute: async (_id: string, { query, limit = 10 }: { query: string; limit?: number }) => { const result = await this._searchDocs(query, limit); return serializeResponseWithMeta(result); }, }; const listLogscaleFunctions: ToolDefinition = { name: "list_logscale_functions", label: "List LogScale functions", description: "List LogScale functions by category or show all categories.", promptSnippet: 'list_logscale_functions("string")', parameters: Type.Object({ category: Type.Optional(Type.String({ description: "Function category" })), }), execute: async (_id: string, { category }: { category?: string }) => { const result = await this._listFunctions(category); return serializeResponseWithMeta(result); }, }; const tools: Record = { search_logscale_docs: searchLogscaleDocs, list_logscale_functions: listLogscaleFunctions, }; if (isFetchEnabled()) { tools.logscale_syntax = { name: "logscale_syntax", label: "LogScale syntax", description: "Fetch LogScale syntax documentation for a topic.", promptSnippet: 'logscale_syntax("filters")', parameters: Type.Object({ topic: Type.String({ description: "Syntax topic" }), max_bytes: Type.Optional(Type.Integer({ description: "Maximum content size in bytes" })), }), execute: async (_id: string, { topic, max_bytes = 20480 }: { topic: string; max_bytes?: number }) => { const result = await this._fetchSyntaxDocs(topic, max_bytes); return chunkAndSerializeResponse(result, "content", chunkingManager); }, }; tools.logscale_function = { name: "logscale_function", label: "LogScale function", description: "Fetch documentation for a specific LogScale function.", promptSnippet: 'logscale_function("sum")', parameters: Type.Object({ function_name: Type.String({ description: "Function name" }), max_bytes: Type.Optional(Type.Integer({ description: "Maximum content size in bytes" })), }), execute: async (_id: string, { function_name, max_bytes = 20480 }: { function_name: string; max_bytes?: number }) => { const result = await this._fetchFunctionDocs(function_name, max_bytes); return chunkAndSerializeResponse(result, "content", chunkingManager); }, }; } return tools; } async _listFunctions(category: string | null = null): Promise> { if (category) { const catLower = category.toLowerCase().trim(); let matchedCat: string | null = null; let matchedInfo: { page: string; description: string } | null = null; if (catLower in FUNCTION_CATEGORIES) { matchedCat = catLower; matchedInfo = FUNCTION_CATEGORIES[catLower]; } else { for (const [key, info] of Object.entries(FUNCTION_CATEGORIES)) { if (catLower.includes(key) || key.includes(catLower)) { matchedCat = key; matchedInfo = info; break; } } } if (!matchedInfo || !matchedCat) { return { error: `Category not found. Available: ${Object.keys(FUNCTION_CATEGORIES).join(", ")}`, categories: Object.keys(FUNCTION_CATEGORIES), source: BASE_URL, }; } try { const url = joinUrl(matchedInfo.page); const soup = await this._fetchPage(url); const functions: Array> = []; for (const link of soup("a[href]").toArray()) { const el = soup(link); const href = String(el.attr("href") ?? ""); if (!href.startsWith("functions-") || !href.endsWith(".html")) { continue; } if (FUNCTION_CATEGORY_PAGES.has(href)) { continue; } const funcName = el.text().trim(); if (funcName && funcName !== href) { functions.push({ name: funcName, url: joinUrl(href), }); } } const seen = new Set(); const uniqueFunctions: Array> = []; for (const func of functions) { const name = String(func.name ?? ""); if (seen.has(name)) { continue; } seen.add(name); uniqueFunctions.push(func); } return { category: matchedCat, description: matchedInfo.description, functions: uniqueFunctions, count: uniqueFunctions.length, url, source: "logscale_docs", }; } catch (error) { return { category, error: `Failed to fetch category: ${error instanceof Error ? error.message : String(error)}`, source: BASE_URL, }; } } return { categories: Object.entries(FUNCTION_CATEGORIES) .sort(([left], [right]) => left.localeCompare(right)) .map(([name, info]) => ({ name, description: info.description, url: joinUrl(info.page), })), total_categories: Object.keys(FUNCTION_CATEGORIES).length, source: BASE_URL, hint: "Use category parameter to list functions in a specific category", }; } } export { LogScaleProvider as LogscaleProvider };