LocalLens
Walkthrough

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:

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;

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.

On this page