import path from "node:path"; import fsp from "node:fs/promises"; export type LocalWikiFormat = "markdown" | "html" | "asciidoc"; export interface LocalWikiSection { title: string; level: number; anchor: string; text: string; } export interface LocalWikiLink { title: string; path: string; } export interface LocalWikiPage { title: string; slug: string; path: string; source?: string; headings: string[]; sections: LocalWikiSection[]; links: LocalWikiLink[]; text: string; mtimeMs: number; } export interface LocalWikiSearchResult { title: string; path: string; source?: string; score: number; matchedFields: string[]; scoreExplanation: string[]; snippet?: string; } export interface LocalWikiCacheMetadata { schemaVersion: number; docsPath: string; generatedAt: string; pageCount: number; newestMtimeMs: number; extra?: Record; } export interface LocalWikiLoadedCache { pages: LocalWikiPage[]; metadata: LocalWikiCacheMetadata; } export interface LocalWikiEngineConfig { displayName: string; docsPath: string; cacheDir: string; schemaVersion?: number; fileExtensions: RegExp; format: LocalWikiFormat; queryExpansions?: Record; searchStopwords?: Iterable; termWeights?: Record; missingDocsMessage?: string; ignoredDirs?: string[]; sourceName?: (filePath: string, docsPath: string) => string | undefined; metadataExtra?: () => Promise>; statusExtra?: () => Promise>; transformText?: (text: string, title: string, filePath: string) => string; titleFromHtml?: (html: string, filePath: string, fallback: string) => string; /** Expand AsciiDoc include:: directives before parsing text/sections. Defaults to true for asciidoc format. */ expandIncludes?: boolean; maxIncludeDepth?: number; } export function createLocalWikiEngine(config: LocalWikiEngineConfig) { const schemaVersion = config.schemaVersion ?? 1; const pagesCache = path.join(config.cacheDir, "pages.json"); const metadataCache = path.join(config.cacheDir, "metadata.json"); const ignoredDirs = new Set([".git", "node_modules", "result", ...(config.ignoredDirs ?? [])]); const missingDocsMessage = config.missingDocsMessage ?? `Local ${config.displayName} docs are not available at ${config.docsPath}.`; const searchStopwords = new Set([...(config.searchStopwords ?? [])].map((word) => normalizeQuery(word)).filter(Boolean)); async function localExists(filePath: string): Promise { try { await fsp.access(filePath); return true; } catch { return false; } } async function listDocFiles(dir: string): Promise { const entries = await fsp.readdir(dir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { if (ignoredDirs.has(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...await listDocFiles(full)); if (entry.isFile() && config.fileExtensions.test(entry.name)) files.push(full); } return files.sort(); } async function available(): Promise { try { const stat = await fsp.stat(config.docsPath); return stat.isDirectory() && (await listDocFiles(config.docsPath)).length > 0; } catch { return false; } } async function stats(): Promise<{ pageCount: number; newestMtimeMs: number }> { if (!await localExists(config.docsPath)) return { pageCount: 0, newestMtimeMs: 0 }; const files = await listDocFiles(config.docsPath); let newestMtimeMs = 0; for (const file of files) newestMtimeMs = Math.max(newestMtimeMs, (await fsp.stat(file)).mtimeMs); return { pageCount: files.length, newestMtimeMs }; } function titleFromPath(filePath: string): string { return path.basename(filePath, path.extname(filePath)).replace(/[-_]+/g, " ").trim(); } function anchorFromHeading(raw: string): string { return raw.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } function normalizeWhitespace(input: string): string { return input.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim(); } function stripMarkdownDecorators(input: string): string { return input .replace(/^#+\s*/, "") .replace(/\s*\{#[^}]+\}\s*$/g, "") .replace(/[*_`~]/g, "") .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") .trim(); } function stripAsciidocDecorators(input: string): string { return input .replace(/^={1,6}\s+/, "") .replace(/^\[\[[^\]]+\]\]\s*/, "") .replace(/xref:([^\[]+)\[([^\]]*)\]/g, (_m, target: string, label: string) => label || titleFromPath(target)) .replace(/https?:[^\[]+\[([^\]]+)\]/g, "$1") .replace(/(?:kbd|btn|menu):\[([^\]]+)\]/g, "$1") .replace(/[*_`]/g, "") .trim(); } function asciidocToText(asciidoc: string): string { return normalizeWhitespace(asciidoc .replace(/^\s*:[^:\n]+:.*$/gm, "") .replace(/^\s*\/\/.*$/gm, "") .replace(/^\[\[[^\]]+\]\]\s*$/gm, "") .replace(/^\[(?:source|console|bash|python|json|ini|subs|NOTE|TIP|IMPORTANT|WARNING|CAUTION)[^\]]*\]\s*$/gim, "") .replace(/^(?:----|====|\+{4}|`{3})\s*$/gm, "") .replace(/^image::[^\[]+\[[^\]]*\]\s*$/gm, "") .replace(/include::([^\[]+)\[[^\]]*\]/g, "") .replace(/xref:([^\[]+)\[([^\]]*)\]/g, (_m, target: string, label: string) => label || titleFromPath(target)) .replace(/https?:[^\[]+\[([^\]]+)\]/g, "$1") .replace(/(?:kbd|btn|menu):\[([^\]]+)\]/g, "$1")); } function stripYamlFrontmatter(markdown: string): string { return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ""); } function yamlFrontmatterTitle(markdown: string): string | undefined { const frontmatter = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); const raw = frontmatter?.[1]?.match(/^title:\s*["']?(.+?)["']?\s*$/m)?.[1]; return raw ? stripMarkdownDecorators(raw) : undefined; } function firstMarkdownHeading(markdown: string): string | undefined { let inFence = false; for (const line of stripYamlFrontmatter(markdown).split(/\n/)) { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const match = line.match(/^#\s+(.+)$/); if (match) return match[1].trim(); } return undefined; } function decodeEntities(input: string): string { const entityMap: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; return input .replace(/&#(x[0-9a-f]+|\d+);/gi, (m, value: string) => { const code = value.toLowerCase().startsWith("x") ? Number.parseInt(value.slice(1), 16) : Number.parseInt(value, 10); return Number.isFinite(code) ? String.fromCodePoint(code) : m; }) .replace(/&([a-z]+);/gi, (m, name: string) => entityMap[name.toLowerCase()] ?? m); } function stripTags(input: string): string { return decodeEntities(input.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); } function markdownSections(markdown: string, fallbackTitle: string): LocalWikiSection[] { const body = stripYamlFrontmatter(markdown); const sections: LocalWikiSection[] = []; let current: LocalWikiSection | undefined; let inFence = false; for (const line of body.split(/\n/)) { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; if (current) current.text += `${line}\n`; continue; } const match = !inFence ? line.match(/^(#{1,6})\s+(.+)$/) : undefined; if (match) { const title = stripMarkdownDecorators(match[2]); if (title.toLowerCase() === "contents") continue; if (current) current.text = current.text.trim(); current = { title, level: match[1].length, anchor: anchorFromHeading(title), text: "" }; sections.push(current); continue; } if (current) current.text += `${line}\n`; } if (!current) sections.push({ title: fallbackTitle, level: 1, anchor: anchorFromHeading(fallbackTitle), text: body.trim() }); else current.text = current.text.trim(); return sections; } function asciidocTitle(asciidoc: string, filePath: string): string { for (const line of asciidoc.split(/\n/)) { const match = line.match(/^(={1,6})\s+(.+)$/); if (match) return stripAsciidocDecorators(match[2]); } return titleFromPath(filePath); } function asciidocSections(asciidoc: string, fallbackTitle: string): LocalWikiSection[] { const sections: LocalWikiSection[] = []; let current: LocalWikiSection | undefined; for (const line of asciidoc.split(/\n/)) { const match = line.match(/^(={1,6})\s+(.+)$/); if (match) { if (current) current.text = asciidocToText(current.text); const title = stripAsciidocDecorators(match[2]); current = { title, level: match[1].length, anchor: anchorFromHeading(title), text: "" }; sections.push(current); continue; } if (current) current.text += `${line}\n`; } if (!current) sections.push({ title: fallbackTitle, level: 1, anchor: anchorFromHeading(fallbackTitle), text: asciidocToText(asciidoc) }); else current.text = asciidocToText(current.text); return sections; } function htmlToText(html: string): string { let body = html.match(/]*>([\s\S]*?)<\/body>/i)?.[1] ?? html; body = body.replace(//gi, " ").replace(//gi, " "); body = body.replace(/<(h[1-6])[^>]*>([\s\S]*?)<\/\1>/gi, (_m, tag: string, inner: string) => `\n\n${"#".repeat(Number(tag.slice(1)))} ${stripTags(inner)}\n\n`); body = body.replace(/]*>([\s\S]*?)<\/pre>/gi, (_m, inner: string) => `\n\n\`\`\`\n${stripTags(inner)}\n\`\`\`\n\n`); body = body.replace(/]*>([\s\S]*?)<\/code>/gi, (_m, inner: string) => `\`${stripTags(inner)}\``); body = body.replace(/]*>/gi, "\n- ").replace(//gi, "\n"); body = body.replace(/<\/(p|div|section|article|table|tr|ul|ol|dl)>/gi, "\n"); return normalizeWhitespace(decodeEntities(body.replace(/<[^>]+>/g, " "))); } function markdownTitle(markdown: string, filePath: string): string { return stripMarkdownDecorators(yamlFrontmatterTitle(markdown) || firstMarkdownHeading(markdown) || titleFromPath(filePath)); } function htmlTitle(html: string, filePath: string): string { const fallback = titleFromPath(filePath); return (config.titleFromHtml?.(html, filePath, fallback) ?? stripTags(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? "")) || fallback; } function candidateLocalPaths(currentFile: string, href: string): string[] { if (/^(https?:|mailto:|#)/i.test(href)) return []; const cleanHref = decodeEntities(href).split("#")[0].split("?")[0].trim(); if (!cleanHref) return []; const variants = [...new Set([cleanHref, cleanHref.replace(/^\.\/+/g, "")].filter(Boolean))]; const expand = (candidate: string): string[] => { if (path.extname(candidate)) return [candidate]; if (config.format === "html") return [`${candidate}.html`, `${candidate}.htm`, path.join(candidate, "index.html")]; if (config.format === "asciidoc") return [`${candidate}.adoc`, `${candidate}.asciidoc`, `${candidate}.asc`, path.join(candidate, "index.adoc")]; return [`${candidate}.md`, `${candidate}.mdx`, `${candidate}.rst`, path.join(candidate, "index.md")]; }; const bases = [path.dirname(currentFile), path.dirname(path.dirname(currentFile)), config.docsPath]; const resolved: string[] = []; for (const variant of variants) { for (const expanded of expand(variant)) { if (path.isAbsolute(expanded)) resolved.push(path.normalize(expanded)); for (const base of bases) resolved.push(path.normalize(path.resolve(base, expanded))); } } return [...new Set(resolved)].filter((candidate) => candidate === config.docsPath || candidate.startsWith(`${config.docsPath}${path.sep}`)); } function resolveLocalPath(currentFile: string, href: string): string | undefined { return candidateLocalPaths(currentFile, href)[0]; } async function resolveExistingLocalPath(currentFile: string, href: string): Promise { for (const candidate of candidateLocalPaths(currentFile, href)) { if (await localExists(candidate)) return candidate; } return undefined; } function markdownLinks(markdown: string, currentFile: string): LocalWikiLink[] { const links = new Map(); for (const match of markdown.matchAll(/\[([^\]]+)\]\(([^\)]+)\)/g)) { const resolved = resolveLocalPath(currentFile, match[2].trim()); if (!resolved) continue; links.set(resolved, { title: stripMarkdownDecorators(match[1]) || titleFromPath(resolved), path: resolved }); } return [...links.values()]; } function asciidocLinks(asciidoc: string, currentFile: string): LocalWikiLink[] { const links = new Map(); const add = (href: string, label: string) => { const resolved = resolveLocalPath(currentFile, href.trim()); if (!resolved) return; links.set(resolved, { title: stripAsciidocDecorators(label) || titleFromPath(resolved), path: resolved }); }; for (const match of asciidoc.matchAll(/xref:([^\[]+)\[([^\]]*)\]/g)) add(match[1], match[2]); for (const match of asciidoc.matchAll(/^include::([^\[]+)\[([^\]]*)\]/gm)) add(match[1], match[2]); return [...links.values()]; } async function expandAsciidocIncludes(raw: string, currentFile: string, depth = 0, seen = new Set()): Promise { const maxDepth = Math.max(0, config.maxIncludeDepth ?? 4); if (depth >= maxDepth) return raw; const includeRe = /^include::([^\[]+)\[[^\]]*\]\s*$/gm; const replacements = await Promise.all([...raw.matchAll(includeRe)].map(async (match) => { const resolved = await resolveExistingLocalPath(currentFile, match[1].trim()); if (!resolved || seen.has(resolved)) return { from: match[0], to: "" }; try { seen.add(resolved); const included = await fsp.readFile(resolved, "utf8"); return { from: match[0], to: await expandAsciidocIncludes(included, resolved, depth + 1, seen) }; } catch { return { from: match[0], to: "" }; } })); let expanded = raw; for (const { from, to } of replacements) expanded = expanded.replace(from, to); return expanded; } function htmlLinks(html: string, currentFile: string): LocalWikiLink[] { const links = new Map(); for (const match of html.matchAll(/]*href=["']([^"'#?]+)(?:#[^"']*)?["'][^>]*>([\s\S]*?)<\/a>/gi)) { const resolved = resolveLocalPath(currentFile, match[1]); if (!resolved) continue; links.set(resolved, { title: stripTags(match[2]) || titleFromPath(resolved), path: resolved }); } return [...links.values()]; } function parsePage(raw: string, filePath: string, mtimeMs: number, sourceRaw = raw): LocalWikiPage { if (config.format === "html") { const title = htmlTitle(sourceRaw, filePath); const baseText = htmlToText(raw); const text = config.transformText?.(baseText, title, filePath) ?? baseText; const sections = markdownSections(text, title); return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: htmlLinks(sourceRaw, filePath), text, mtimeMs }; } if (config.format === "asciidoc") { const title = asciidocTitle(sourceRaw, filePath); const baseText = asciidocToText(raw); const text = config.transformText?.(baseText, title, filePath) ?? baseText; const sections = asciidocSections(raw, title); return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: asciidocLinks(sourceRaw, filePath), text, mtimeMs }; } const title = markdownTitle(sourceRaw, filePath); const markdownBody = stripYamlFrontmatter(raw); const baseText = normalizeWhitespace(markdownBody); const text = config.transformText?.(baseText, title, filePath) ?? baseText; const sections = markdownSections(text, title); return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: markdownLinks(markdownBody, filePath), text, mtimeMs }; } function limitText(text: string, maxChars = 12000): { text: string; truncated: boolean } { return text.length <= maxChars ? { text, truncated: false } : { text: `${text.slice(0, maxChars)}\n\n[truncated at ${maxChars} characters]`, truncated: true }; } async function buildCache(): Promise { if (!await available()) throw new Error(missingDocsMessage); await fsp.mkdir(config.cacheDir, { recursive: true }); const files = await listDocFiles(config.docsPath); const pages: LocalWikiPage[] = []; let newestMtimeMs = 0; for (const file of files) { const stat = await fsp.stat(file); newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs); const raw = await fsp.readFile(file, "utf8"); const shouldExpandIncludes = config.format === "asciidoc" && config.expandIncludes !== false; const expanded = shouldExpandIncludes ? await expandAsciidocIncludes(raw, file) : raw; pages.push(parsePage(expanded, file, stat.mtimeMs, raw)); } const metadata: LocalWikiCacheMetadata = { schemaVersion, docsPath: config.docsPath, generatedAt: new Date().toISOString(), pageCount: pages.length, newestMtimeMs, extra: await config.metadataExtra?.() }; await fsp.writeFile(pagesCache, JSON.stringify(pages, null, 2)); await fsp.writeFile(metadataCache, JSON.stringify(metadata, null, 2)); return { pages, metadata }; } async function cacheFresh(metadata: LocalWikiCacheMetadata): Promise { const current = await stats(); return metadata.schemaVersion === schemaVersion && metadata.docsPath === config.docsPath && metadata.pageCount === current.pageCount && metadata.newestMtimeMs === current.newestMtimeMs; } async function loadCache(): Promise { try { const [pagesRaw, metadataRaw] = await Promise.all([fsp.readFile(pagesCache, "utf8"), fsp.readFile(metadataCache, "utf8")]); const metadata = JSON.parse(metadataRaw) as LocalWikiCacheMetadata; if (await cacheFresh(metadata)) return { pages: JSON.parse(pagesRaw) as LocalWikiPage[], metadata }; } catch {} return buildCache(); } function normalizeQuery(input: string): string { return input.toLowerCase().replace(/[^a-z0-9_./+-]+/g, " ").trim(); } function expandQuery(query: string): string[] { const tokens = normalizeQuery(query).split(/\s+/).filter((token) => token && !searchStopwords.has(token)); const expanded = new Set(tokens); for (const token of tokens) { for (const extra of config.queryExpansions?.[token] ?? []) { const normalized = normalizeQuery(extra); if (normalized && !searchStopwords.has(normalized)) expanded.add(normalized); } } return [...expanded].filter(Boolean); } function tokenWeight(token: string): number { return config.termWeights?.[token] ?? 1; } function makeSnippet(text: string, tokens: string[], max = 280): string | undefined { const lower = text.toLowerCase(); const index = tokens.map((t) => lower.indexOf(t.toLowerCase())).filter((i) => i >= 0).sort((a, b) => a - b)[0]; if (index === undefined) return undefined; const start = Math.max(0, index - Math.floor(max / 2)); const snippet = text.slice(start, start + max).replace(/\s+/g, " ").trim(); return `${start > 0 ? "…" : ""}${snippet}${start + max < text.length ? "…" : ""}`; } function scorePage(page: LocalWikiPage, tokens: string[]): LocalWikiSearchResult | undefined { const title = normalizeQuery(page.title); const slug = normalizeQuery(page.slug); const source = normalizeQuery(page.source ?? ""); const headings = normalizeQuery(page.headings.join(" ")); const text = normalizeQuery(page.text); let score = 0; const matchedFields = new Set(); const scoreExplanation: string[] = []; for (const token of tokens) { const weight = tokenWeight(token); if (title.includes(token)) { score += 25 * weight; matchedFields.add("title"); scoreExplanation.push(`title matched '${token}'`); } if (slug.includes(token)) { score += 12 * weight; matchedFields.add("slug"); } if (source.includes(token)) { score += 8 * weight; matchedFields.add("source"); } if (headings.includes(token)) { score += 10 * weight; matchedFields.add("headings"); } const textMatches = text.split(token).length - 1; if (textMatches > 0) { score += Math.min(15, textMatches) * weight; matchedFields.add("text"); } } return score > 0 ? { title: page.title, path: page.path, source: page.source, score: Number(score.toFixed(2)), matchedFields: [...matchedFields], scoreExplanation, snippet: makeSnippet(page.text, tokens) } : undefined; } function findPage(pages: LocalWikiPage[], pageRef: string): LocalWikiPage | undefined { const normalized = normalizeQuery(pageRef); return pages.find((p) => p.path === pageRef) ?? pages.find((p) => normalizeQuery(p.slug) === normalized) ?? pages.find((p) => normalizeQuery(p.title) === normalized) ?? pages.find((p) => normalizeQuery(p.slug).includes(normalized) || normalizeQuery(p.title).includes(normalized)); } async function status() { const currentAvailable = await available(); const currentStats = await stats(); let cacheGeneratedAt: string | undefined; try { cacheGeneratedAt = (JSON.parse(await fsp.readFile(metadataCache, "utf8")) as LocalWikiCacheMetadata).generatedAt; } catch {} return { displayName: config.displayName, docsPath: config.docsPath, available: currentAvailable, pageCount: currentStats.pageCount, cacheGeneratedAt, ...(await config.statusExtra?.()) }; } async function search(params: { query: string; limit?: number; includeSnippets?: boolean }) { const { pages } = await loadCache(); const tokens = expandQuery(params.query); const limit = Math.max(1, Math.min(params.limit ?? 8, 50)); const results = pages.map((p) => scorePage(p, tokens)).filter((x): x is LocalWikiSearchResult => Boolean(x)).sort((a, b) => b.score - a.score).slice(0, limit); return { query: params.query, expandedTokens: tokens, results: params.includeSnippets === true ? results : results.map(({ snippet, ...rest }) => rest) }; } async function loadPage(pageRef: string): Promise { const cache = await loadCache(); const page = findPage(cache.pages, pageRef); if (!page) throw new Error(`No ${config.displayName} page matched '${pageRef}'. Try a local wiki search first.`); return page; } async function read(params: { page: string; maxChars?: number }) { const page = await loadPage(params.page); const limited = limitText(page.text, params.maxChars ?? 20000); return { title: page.title, source: page.source, path: page.path, citation: `${page.path} — ${page.title}`, truncated: limited.truncated, text: limited.text }; } async function sections(params: { page: string; maxSections?: number }) { const page = await loadPage(params.page); const maxSections = Math.max(1, Math.min(params.maxSections ?? 80, 300)); const selected = page.sections.slice(0, maxSections); return { title: page.title, source: page.source, path: page.path, sectionCount: page.sections.length, omittedSectionCount: Math.max(0, page.sections.length - selected.length), sections: selected.map((s) => ({ title: s.title, level: s.level, anchor: s.anchor })) }; } async function extract(params: { page: string; section?: string; query?: string; maxChars?: number; maxSections?: number }) { const page = await loadPage(params.page); let matchedSections = page.sections; if (params.section) { const needle = normalizeQuery(params.section); matchedSections = matchedSections.filter((s) => normalizeQuery(s.title).includes(needle)); } if (params.query) { const tokens = expandQuery(params.query); matchedSections = matchedSections.map((section) => ({ section, score: tokens.reduce((sum, token) => sum + (normalizeQuery(`${section.title} ${section.text}`).includes(token) ? tokenWeight(token) : 0), 0) })).filter((i) => i.score > 0).sort((a, b) => b.score - a.score).map((i) => i.section); } const maxSections = Math.max(1, Math.min(params.maxSections ?? (params.section || params.query ? 6 : 5), 50)); const totalMatchedSections = matchedSections.length; matchedSections = matchedSections.slice(0, maxSections); const joined = matchedSections.map((s) => `${"#".repeat(Math.min(s.level, 6))} ${s.title}\n\n${s.text}`).join("\n\n"); const limited = limitText(joined || page.text, params.maxChars ?? 10000); return { title: page.title, source: page.source, path: page.path, citation: `${page.path} — ${matchedSections.map((s) => s.title).join(", ") || page.title}`, matchedSections: matchedSections.map((s) => ({ title: s.title, level: s.level, anchor: s.anchor })), totalMatchedSections, omittedSectionCount: Math.max(0, totalMatchedSections - matchedSections.length), truncated: limited.truncated, text: limited.text }; } async function related(params: { page: string; limit?: number }) { const page = await loadPage(params.page); const limit = Math.max(1, Math.min(params.limit ?? 10, 50)); return { title: page.title, source: page.source, path: page.path, links: page.links.slice(0, limit) }; } return { available, stats, buildCache, loadCache, status, search, read, sections, extract, related, expandQuery, listDocFiles }; }