5. Adaptadores de archivos
Recorre una carpeta, lee archivos de texto, normaliza la entrada del file picker del navegador.
src/files.ts hace dos cosas y se rehúsa a hacer cualquier otra:
- Camino local: recorre una carpeta, lee archivos de texto,
devuelve
LocalDocument[]. - Camino del navegador: toma la entrada del file picker y
produce la misma forma
LocalDocument[].
Sin chunking. Sin embedding. Sin QVAC. Esta es la costura entre archivos en disco o en un navegador y datos tipados que el resto de la app entiende.
Filtros
Antes del código, dos constantes fijan la política:
const supportedExtensions = new Set(
".css .html .js .jsx .json .md .mdx .ts .tsx .txt .yaml .yml".split(" "),
);
const ignoredDirectories = new Set(
".git .locallens .next .turbo build coverage dist node_modules".split(" "),
);
const maxFileBytes = 2 * 1024 * 1024;Estos deciden qué se convierte en un LocalDocument:
- solo extensiones con forma de texto;
- nunca entrar a cachés, lockdirs o build output;
- saltar archivos más grandes de 2 MB.
Lo que pasa el filtro se filtra después por null bytes embebidos
(\u0000) y contenido vacío. Ambos indican payloads no-texto que
un chunker de markdown no debería intentar manejar.
Recorriendo una carpeta local
export async function discoverTextDocuments(rootPath: string): Promise<LocalDocument[]> {
const root = path.resolve(rootPath);
const rootStats = await stat(root).catch(() => null);
if (!rootStats?.isDirectory()) throw new AppError(`Folder not found: ${root}`, 404);
const documents: LocalDocument[] = [];
for (const absolutePath of await walk(root)) {
const fileStats = await stat(absolutePath);
if (fileStats.size > maxFileBytes) continue;
const content = await readFile(absolutePath, "utf8").catch(() => "");
if (!content.trim() || content.includes("\u0000")) continue;
const relativePath = path.relative(root, absolutePath);
documents.push({
relativePath,
content,
checksum: createHash("sha256").update(`${relativePath}\u0000${content}`).digest("hex"),
bytes: fileStats.size,
});
}
return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}Dos decisiones de diseño que vale la pena marcar:
- El checksum se computa desde
${relativePath}\u0000${content}. El separador de null byte no puede aparecer dentro de ninguno de los dos campos, así que un archivo movido dentro del cerebro produce un checksum distinto aunque su contenido no haya cambiado. Eso es correcto: los IDs de chunk incluyen la ruta, así que los embeddings necesitan invalidarse. - El resultado se ordena por ruta relativa. Orden estable significa índices de chunk estables, lo que mantiene las citas estables entre reindexaciones.
Normalizando entrada del navegador
export function browserDocumentsFromInput(inputs: BrowserDocumentInput[]): LocalDocument[] {
return inputs
.map((input) => {
const relativePath = normalizeBrowserRelativePath(input.relativePath);
const content = typeof input.content === "string" ? input.content : "";
const bytes =
Number.isFinite(input.bytes) && input.bytes >= 0
? input.bytes
: new TextEncoder().encode(content).byteLength;
if (!relativePath || !isSupportedPath(relativePath) || !content.trim()) return undefined;
if (content.includes("\u0000") || bytes > maxFileBytes) return undefined;
return {
relativePath,
content,
checksum: createHash("sha256").update(`${relativePath}\u0000${content}`).digest("hex"),
bytes,
};
})
.filter((document): document is LocalDocument => Boolean(document))
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}Mismas reglas que el walk local:
- solo extensiones soportadas;
- sin null bytes;
- sin archivos >2 MB;
- contenido vacío rechazado.
La diferencia es que el navegador no nos dice de dónde en el disco
vino el archivo — solo qué eligió el usuario relativo a la raíz
que escogió. Así que la función rechaza segmentos .. y rutas con
prefijo de punto para mantener limpio el espacio de rutas
relativas:
function normalizeBrowserRelativePath(value: string): string | undefined {
const parts = value
.replace(/\\/g, "/")
.split("/")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) return undefined;
return parts.join("/");
}Sanitizando nombres de carpeta
export function sanitizeFolderName(value: string): string {
return (
value
.trim()
.replace(/[\\/]+/g, "-")
.replace(/[^a-zA-Z0-9._ -]+/g, "")
.replace(/\s+/g, " ")
.slice(0, 80) || "selected-folder"
);
}Esto produce la ruta virtual browser://my-folder almacenada en
el cerebro. Deliberadamente estricto: sin slashes, sin caracteres
raros. Los nombres de carpeta del picker nunca pueden verse como
rutas reales.
¿Por qué un tope de 2 MB?
La mayoría de los archivos de documentación son chicos. El techo de 2 MB es una protección contra indexar accidentalmente un artefacto de build o un archivo de datos grande que se coló en la carpeta. Si quieres indexar archivos más grandes, sube el límite en un solo lugar — pero piensa primero en el costo de embedding.
Lo que sigue: LocalLensApp,
donde todo esto se conecta.