LocalLens
ウォークスルー

5. ファイルアダプタ

フォルダを走査してテキストファイルを読み、ブラウザのファイルピッカー入力を正規化する。

src/files.ts は 2 つのことだけをして、それ以外は引き受けません。

  • ローカルパス: フォルダを走査してテキストファイルを読み、 LocalDocument[] を返す。
  • ブラウザパス: ファイルピッカーの入力を受け取り、同じ LocalDocument[] の形を生成する。

チャンク化なし、embedding 化なし、QVAC なし。これはディスクや ブラウザにあるファイルと、アプリの残りが理解する型付きデータとの つなぎ目です。

フィルタ

コードの前に、ポリシーを決める定数が 2 つあります。

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;

これらが何を LocalDocument にするかを決めます。

  • テキスト形式の拡張子のみを対象にする
  • キャッシュ、ロック、ビルド成果物の中には決して再帰しない
  • 2 MB を超えるファイルはスキップする

これらを通過したものは、さらに埋め込まれた null バイト(\u0000)と 空のコンテンツがないかでフィルタされます。どちらも、Markdown 用の チャンク分割器が扱うべきでない非テキストペイロードを示します。

ローカルフォルダの走査

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));
}

押さえておきたい設計上の選択が 2 つあります。

  • checksum は ${relativePath}\u0000${content} から計算されます。null バイトのセパレータはどちらのフィールドの中にも現れないので、brain 内で ファイルが移動した場合、たとえ内容が変わっていなくても異なる checksum が 生成されます。これは正しい挙動です。チャンク ID にはパスが含まれるので、 embedding は無効化されるべきだからです。
  • 結果は相対パスでソートされます。安定した順序は安定したチャンク インデックスを意味し、再インデックスをまたいでも引用が安定します。

ブラウザ入力の正規化

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));
}

ローカルの走査と同じルールです。

  • 対応拡張子のみ
  • null バイトなし
  • 2 MB を超えるファイルなし
  • 空のコンテンツは却下

違いは、ブラウザが「ディスク上のどこから来たか」を教えてくれないこと です。教えてくれるのは、ユーザーが選んだルートからの相対関係だけです。 そこで、.. セグメントとドット始まりのパスを却下して、相対パスの空間を 綺麗に保ちます。

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("/");
}

フォルダ名のサニタイズ

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"
  );
}

これが brain に格納される browser://my-folder の仮想パスを生成します。 意図的に厳しめです。スラッシュなし、変な文字なし。ピッカーから来た フォルダ名が本物のパスのように見えることはありません。

なぜ 2 MB の上限なのか?

ドキュメントファイルのほとんどは小さいです。2 MB の天井は、フォルダに 紛れ込んだビルド成果物や大きなデータファイルを誤ってインデックスして しまうのを防ぐ正気のガードです。もっと大きなファイルをインデックスしたい 場合は、1 か所で上限を上げてください。ただし、embedding コストはまず 考えるべきです。

次は LocalLensApp、ここまでのすべてを 配線する場所です。

On this page