import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; /** * Lectura directa de los markdown del knowledge base desde el filesystem. * * Orden de resolucion del directorio de docs: * 1. `ACAI_DOCS_DIR` — override explicito por entorno (si esta definido y no vacio). * 2. `/docs` — caso principal: cada proyecto/web tiene su * propio `docs/`. El `.mcp.json` inyecta `ACAI_PROJECT_DIR` (p.ej. * `/opt/acai/webs//`), funciona tanto en local (VSCode) como * en cloud (agentic). * 3. `/app/docs` — fallback final: container `agentic` donde esta horneada la * copia canonica de los .md. */ function dirExists(p) { try { return existsSync(p); } catch { return false; } } function resolveDocsDir() { // 1. Override explicito const override = process.env.ACAI_DOCS_DIR; if (override && override.trim() !== "") return override; // 2. Docs del proyecto/web const projectDir = process.env.ACAI_PROJECT_DIR; if (projectDir && projectDir.trim() !== "") { const projectDocs = path.join(projectDir, "docs"); if (dirExists(projectDocs)) return projectDocs; } // 3. Fallback al container agentic return "/app/docs"; } export async function listDocs() { const dir = resolveDocsDir(); let files; try { files = await fs.readdir(dir); } catch (err) { const error = new Error(`No se pudo leer el directorio de docs (${dir}): ${err.message}`); error.code = "DOCS_DIR_NOT_FOUND"; throw error; } const mdFiles = files.filter((f) => f.endsWith(".md")).sort(); const docs = []; for (const file of mdFiles) { const id = file.replace(/\.md$/i, ""); const filePath = path.join(dir, file); const content = await fs.readFile(filePath, "utf8"); const lines = content.split("\n"); const titleLine = lines.find((l) => l.trim().startsWith("# ")) || id; const title = titleLine.replace(/^#+/, "").trim(); // Summary: primeras 30 lineas no-heading, capeado a 500 chars const summaryLines = []; for (const line of lines.slice(0, 30)) { const t = line.trim(); if (t && !t.startsWith("#")) summaryLines.push(t); if (summaryLines.join(" ").length > 500) break; } const summary = summaryLines.join(" ").slice(0, 500); docs.push({ id, title, summary, chars: content.length, }); } return docs; } export async function readDoc(name, section) { const dir = resolveDocsDir(); const safeName = String(name).replace(/\.md$/i, "").replace(/[\/\\]/g, ""); const filePath = path.join(dir, `${safeName}.md`); let content; try { content = await fs.readFile(filePath, "utf8"); } catch (err) { const error = new Error(`Doc '${safeName}' no encontrado en ${dir}`); error.code = "DOC_NOT_FOUND"; throw error; } const titleLine = content.split("\n").find((l) => l.trim().startsWith("# ")) || safeName; const title = titleLine.replace(/^#+/, "").trim(); const availableSections = listSections(content); if (section) { const sectionContent = extractSection(content, section); if (sectionContent === null) { return { id: safeName, title, section_requested: section, section_found: false, available_sections: availableSections, chars: 0, content: "", }; } return { id: safeName, title, section, section_found: true, chars: sectionContent.length, content: sectionContent, }; } return { id: safeName, title, section: null, section_found: true, chars: content.length, available_sections: availableSections, content, }; } function listSections(content) { const sections = []; for (const line of content.split("\n")) { const t = line.trimStart(); if (t.startsWith("## ") && !t.startsWith("### ")) { sections.push(t.slice(3).trim()); } } return sections; } function extractSection(content, query) { const q = String(query).toLowerCase().trim(); if (!q) return null; const lines = content.split("\n"); const captured = []; let capturing = false; for (const line of lines) { const t = line.trimStart(); const isH2 = t.startsWith("## ") && !t.startsWith("### "); if (isH2) { const heading = t.slice(3).trim(); if (capturing) break; if (heading.toLowerCase().includes(q)) { capturing = true; captured.push(line); continue; } } if (capturing) captured.push(line); } if (captured.length === 0) return null; return captured.join("\n").trimEnd(); }