LocalLens
Walkthrough

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:

src/files.ts
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.

On this page