This commit is contained in:
Jordan Diaz
2026-04-25 10:27:51 +00:00
parent e84a36c83d
commit 6881d64a08
42 changed files with 3207 additions and 3413 deletions

View File

@@ -0,0 +1,151 @@
import fs from "node:fs/promises";
import path from "node:path";
/**
* Lectura directa de los markdown del knowledge base desde el filesystem.
*
* El MCP server corre dentro del container `agentic` junto al FastAPI, asi
* que los .md viven en `/app/docs/` (la imagen los copia ahi).
*
* En caso de override por entorno, respeta `ACAI_DOCS_DIR`. En desarrollo
* fuera del container, fallback a paths relativos al cwd.
*/
function resolveDocsDir() {
const override = process.env.ACAI_DOCS_DIR;
if (override) return override;
// Container path
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();
}

View File

@@ -0,0 +1,20 @@
import { registerListDocsTool } from './list_docs.js';
import { registerReadDocTool } from './read_doc.js';
/**
* Tools para consultar la documentación del proyecto bajo demanda.
*
* El knowledge_base del context engine ya inyecta los docs más relevantes
* en cada turno por similitud semántica, pero hay dos casos en los que el
* agente necesita pedir un doc explícitamente:
* - El doc cayó fuera del top-K y solo aparece en el title-index del KB.
* - Se necesita una sección puntual con detalle sin cargar todo el doc.
*
* Estas tools delegan en endpoints `/api/knowledge/...` del server Python,
* que leen los docs del memory store (Redis) — la misma fuente que alimenta
* el knowledge_base.
*/
export function registerDocsTools(server) {
registerListDocsTool(server);
registerReadDocTool(server);
}

View File

@@ -0,0 +1,29 @@
import { handleToolError } from "../helpers/errorHandler.js";
import { listDocs } from "./_docsReader.js";
export function registerListDocsTool(server) {
server.tool(
"list_docs",
`Lista todos los docs disponibles en el knowledge base con su id, título y un summary corto. Útil cuando necesitas saber qué documentación existe antes de llamar a 'read_doc'. El system prompt y el knowledge_base ya cargan los docs más relevantes a tu tarea — usa 'list_docs' / 'read_doc' solo si necesitas un doc que no apareció completo en el contexto o quieres una sección específica con detalle.`,
{},
{ readOnlyHint: true, destructiveHint: false },
async () => {
try {
const docs = await listDocs();
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
count: docs.length,
documents: docs,
note: "Usa 'read_doc' con el 'id' (e.g. '05-tables-and-fields') para leer el doc completo o pasa 'section' para una sección concreta.",
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "list_docs", {});
}
}
);
}

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { readDoc } from "./_docsReader.js";
export function registerReadDocTool(server) {
server.tool(
"read_doc",
`Lee un documento del knowledge base de Acai bajo demanda.
Cuándo usarlo:
- El doc no se cargó completo en el contexto (aparece en "Other Available Docs" del knowledge_base).
- Necesitas una sección concreta con detalle sin cargar todo el doc.
- Vas a hacer una operación delicada y quieres releer las reglas exactas (e.g. crear una tabla, escribir un hook, editar el header).
Parámetros:
- name: id del doc (sin extensión). Ejemplos: '01-builder-fields', '05-tables-and-fields', '09-mcp-tools-reference'. Usa 'list_docs' si dudas del id.
- section: opcional. Match case-insensitive y parcial sobre headings H2 ('## ...'). Devuelve desde el heading hasta el siguiente H2.
Si la sección no existe, la respuesta incluye 'available_sections' para que reintentes con un nombre válido.
Docs disponibles (resumen):
- 01-builder-fields — Campos editables (data-field-type), atributos Acai (c-if, c-for, c-class), c-form, componentes built-in.
- 02-twig — Filtros Twig (get, queryDB, hook, module, imagec, translate, raw).
- 03-modules-and-sections — Módulos vs secciones generales, thisrecord, multiv2, custom-{tableName}.
- 04-pages-and-records — Builder vs Standard, menuType, apartados, reglas sobre enlace/controlador.
- 05-tables-and-fields — Schema, create_table, create_field, tipos de campo, casos destructivos.
- 06-hooks-and-cmsapi — Hooks PHP, CmsApi/CocoDB, hook middleware.
- 07-css-js-conventions — Tailwind+BEM, scoping, Vue 3, componentes nativos.
- 08-layout-and-libraries — get/set_layout_field, librerías globales, regla de no editar layout.json.
- 09-mcp-tools-reference — Inventario completo + workflows canónicos.
- 10-production-patterns — Patrones reales (cabecera, zigzag, FAQ, formulario, detalle).
- 11-quick-reference — Cheat sheet con todas las reglas y formatos.`,
{
name: z.string().describe("ID del doc sin extensión (e.g. '05-tables-and-fields')"),
section: z.string().optional().describe("Heading H2 a extraer (case-insensitive, parcial). Omitir para leer el doc completo."),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ name, section }) => {
try {
const validationError = validateRequired({ name }, ["name"], "read_doc");
if (validationError) return validationError;
const data = await readDoc(name, section);
return {
content: [{
type: "text",
text: JSON.stringify(data, null, 2),
}],
};
} catch (error) {
if (error?.code === "DOC_NOT_FOUND") {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: error.message,
hint: "Usa 'list_docs' para ver los ids disponibles. Los ids tienen prefijo numérico (e.g. '05-tables-and-fields').",
}, null, 2),
}],
isError: true,
};
}
return handleToolError(error, "read_doc", { name, section });
}
}
);
}