5. ファイルアダプタ
フォルダを走査してテキストファイルを読み、ブラウザのファイルピッカー入力を正規化する。
src/files.ts は 2 つのことだけをして、それ以外は引き受けません。
- ローカルパス: フォルダを走査してテキストファイルを読み、
LocalDocument[]を返す。 - ブラウザパス: ファイルピッカーの入力を受け取り、同じ
LocalDocument[]の形を生成する。
チャンク化なし、embedding 化なし、QVAC なし。これはディスクや ブラウザにあるファイルと、アプリの残りが理解する型付きデータとの つなぎ目です。
フィルタ
コードの前に、ポリシーを決める定数が 2 つあります。
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、ここまでのすべてを
配線する場所です。