PAIPAI

@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, metadata

Scope 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

  • read returns a ReadableStream<Uint8Array> backed by fs.createReadStream, so large files are streamed rather than buffered.
  • prepareForModel reads 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.
  • list scans metadata sidecars in the exact hashed scope directory, returns newest-first pages, and can filter by the userKey and threadId recorded at upload time.
  • createUrl is not implemented. Callers using provider.createUrl?.(…) get undefined, which signals "this backend cannot mint URLs" — fall back to read() 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 });
  },
});

On this page