5. Adaptadores de arquivo
Caminhar por uma pasta, ler arquivos de texto, normalizar input do file picker do navegador.
src/files.ts faz duas coisas e se recusa a fazer qualquer outra:
- Caminho local: caminha por uma pasta, lê arquivos de texto, retorna
LocalDocument[]. - Caminho do navegador: pega o input do file picker e produz o
mesmo formato
LocalDocument[].
Sem chunking. Sem embedding. Sem QVAC. Essa é a costura entre arquivos no disco ou num navegador e dados tipados que o resto do app entende.
Filtros
Antes do código, duas constantes definem a 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;Essas decidem o que vira LocalDocument:
- só extensões formato texto;
- nunca recursionar em caches, lockdirs ou build output;
- pular arquivos maiores que 2 MB.
O que passa é então filtrado por null bytes embutidos
(\u0000) e conteúdo vazio. Os dois indicam payloads não-texto
que um chunker de markdown não deveria tentar tratar.
Caminhando por uma pasta 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));
}Duas escolhas de design que vale apontar:
- O checksum é computado de
${relativePath}\u0000${content}. O separador null-byte não pode aparecer dentro de nenhum dos campos, então um arquivo movido dentro do brain produz um checksum diferente mesmo se o conteúdo não tiver mudado. Isso está correto: IDs de chunk incluem o caminho, então os embeddings precisam invalidar. - O resultado é ordenado por caminho relativo. Ordem estável significa índices de chunk estáveis, o que mantém citações estáveis entre reindexações.
Normalizando input do 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));
}Mesmas regras da caminhada local:
- só extensões suportadas;
- sem null bytes;
- sem arquivos >2 MB;
- conteúdo vazio rejeitado.
A diferença é que o navegador não nos diz onde no disco
o arquivo veio — só o que o usuário escolheu relativo à
raiz que escolheu. Então a função rejeita segmentos .. e
caminhos com prefixo de ponto para manter o espaço de caminho relativo limpo:
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 nomes de pasta
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"
);
}Isso produz o caminho virtual browser://my-folder guardado no
brain. Estrito de propósito: sem barras, sem caracteres engraçados. Nomes de pasta
do picker nunca conseguem parecer com caminhos reais.
Por que um teto de 2 MB?
A maior parte dos arquivos de documentação é pequena. O teto de 2 MB é uma guarda contra indexar acidentalmente um artefato de build ou um arquivo de dados grande que escorregou para dentro da pasta. Se você quer indexar arquivos maiores, aumente o limite em um lugar só — mas pensa antes no custo de embedding.
A seguir: LocalLensApp, onde
tudo isso é conectado.