@pai/files-fs
Filesystem-backed FileProvider implementation for local development.
Package: @pai/files-fs
Persists each file as a body + metadata sidecar under a root directory. Suitable for local development and single-process services that need uploaded attachments to survive across restarts.
Single-process only. Concurrent writers from separate processes against the same rootDir are not coordinated. Use a cloud-backed provider (S3, GCS, R2, …) for multi-worker deployments.
Quick start
import { createAgentRuntime } from "@pai/core";
import { createFileSystemFileProvider } from "@pai/files-fs";
const runtime = createAgentRuntime({
agent,
scopeKey: (identity) => identity.workspaceId,
files: createFileSystemFileProvider({
rootDir: "./.pai/files",
}),
});Options
type FileSystemFileProviderOptions = FileProviderCommonOptions & {
/** Root directory for all stored files. Created on demand. */
rootDir: string;
/**
* Override how fileIds are minted. Defaults to `randomUUID()`. Generated
* ids must match `/^(?!\.+$)[A-Za-z0-9._-]+$/` — they are used directly
* as filenames and a stricter shape prevents path traversal.
*/
generateId?: () => string;
};The factory throws if rootDir is missing or empty.
beforeSave from FileProviderCommonOptions runs before the body and metadata sidecar are written. Use it for synchronous validation or normalization before anything reaches disk. When set, maxFileBytes rejects oversized saves while stream bodies are written to the temporary file.
On-disk layout
<rootDir>/
<sha256(scopeKey)>/
<fileId>.bin -- raw bytes
<fileId>.json -- creator, origin thread, save order, mediaType, filename, byteSize, metadataScope keys are hashed with sha256 so the on-disk directory name is bounded in length and safe regardless of what characters appear in scopeKey. File ids become filenames directly, so custom generateId functions must produce path-safe ids.
Writes are atomic (temp file + rename). On save the body is written first, then
the metadata sidecar — the metadata is the source of truth for presence, so
head, read, and list never return a file whose body has not finished
writing. On delete the sidecar is removed first for the same reason. The
provider's internal save-order field gives pagination a stable newest-first
cursor; it is not part of UploadedFile.
File listing requires the savedOrder field written by this version. Recreate
or migrate older sidecars before calling list; the provider rejects them
rather than returning an unstable continuation.
Behaviour notes
readreturns aReadableStream<Uint8Array>backed byfs.createReadStream, so large files are streamed rather than buffered.prepareForModelreads the full file into memory and returns{ kind: "bytes" }. Model adapters get the raw bytes; the local filesystem cannot mint URLs that a remote model could fetch.listscans metadata sidecars in the exact hashed scope directory, returns newest-first pages, and can filter by theuserKeyandthreadIdrecorded at upload time.createUrlis not implemented. Callers usingprovider.createUrl?.(…)getundefined, which signals "this backend cannot mint URLs" — fall back toread()for inline previews.
Conformance
createFileSystemFileProvider passes the full @pai/files/test conformance suite. The package's tests use a fresh temp directory per createProvider call:
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFileProviderConformanceSuite } from "@pai/files/test";
import { createFileSystemFileProvider } from "@pai/files-fs";
const dirs: string[] = [];
afterAll(() =>
Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true }))),
);
createFileProviderConformanceSuite({
name: "createFileSystemFileProvider",
createProvider: async () => {
const dir = await mkdtemp(join(tmpdir(), "pai-files-fs-"));
dirs.push(dir);
return createFileSystemFileProvider({ rootDir: dir });
},
});