/** * Browser-only Origin Private File System storage for spooled artifacts. * * @module @nhtio/adk/batteries/storage/opfs * * @remarks * Opt-in **browser-only** storage battery backed by the * [Origin Private File System](https://developer.mozilla.org/docs/Web/API/File_System_API/Origin_private_file_system) * (OPFS). Provides {@link OpfsSpoolReader} (a {@link @nhtio/adk!SpoolReader} over a `OpfsFileHandle`) * and {@link OpfsSpoolStore} (a `write(callId, bytes) → reader` persistence layer that wraps an * OPFS directory). * * The reader has two modes selected lazily on first method invocation based on the size of the * underlying file: * * - **Eager mode** — when `file.size` is below `streamThresholdBytes` (default 10 MiB), the * reader calls `file.text()` once, splits the content on `\n`, and caches lines + byte count. * All subsequent calls resolve from memory. * - **Streaming mode** — when `file.size` meets or exceeds the threshold, the reader streams the * file once via `file.stream().getReader()` to build a line-offset index (`number[]` of byte * offsets per line), then serves each `line(i)` request by slicing the underlying `Blob` — * `Blob.slice(start, end).text()` decodes only the requested range, no head-of-file scan. * Caps RAM at one index + one line buffer regardless of file size. * * The store auto-selects its write API by execution scope: * * - In **worker scopes** (`self instanceof WorkerGlobalScope`), it acquires a * `FileSystemSyncAccessHandle` and writes synchronously. Sync handles are the only API * available in workers and the fastest path for the spool-write hot path. * - On the **main thread**, it uses `OpfsFileHandle.createWritable()` and the async * stream API. Sync access handles are not exposed on the main thread. * * This module assumes a browser-equivalent runtime — `navigator.storage`, * `OpfsFileHandle`, `TextEncoder`/`TextDecoder`, and `Blob` must all exist. It must not * be imported from Node code; do so and you will fail at resolve time when `navigator` is * referenced. * * @example * ```ts * import { OpfsSpoolStore } from '@nhtio/adk/batteries/storage/opfs' * * const store = new OpfsSpoolStore({ keyPrefix: 'agent-runs/' }) * const reader = await store.write(callId, bytes) * const Ctor = tool.artifactConstructor?.() ?? SpooledArtifact * const artifact = new Ctor(reader) * ``` */ import type { ReaderDescriptor, SpoolReader, SpoolStore } from "../../../common"; /** * Resolver tag for the OPFS spool reader handle. The locator carries the OPFS file `name`; the live OPFS * root directory is re-injected by the consumer-registered resolver on decode (the name alone cannot * re-open the file). */ export declare const SPOOL_READER_TAG_OPFS = "spool:opfs"; /** * Minimal subset of the * [File System Access](https://developer.mozilla.org/docs/Web/API/File_System_API) * `OpfsFileHandle` interface that this module touches at runtime. Structurally compatible * with the DOM-lib `OpfsFileHandle` — at call sites you pass real OPFS handles directly. */ export interface OpfsFileHandle { /** Discriminant: always `'file'`. */ readonly kind: 'file'; /** The entry's name. */ readonly name: string; /** Resolve a readable {@link OpfsFile} snapshot of the handle's contents. */ getFile(): Promise; /** Open a writable stream that replaces the file's contents. */ createWritable(): Promise; } /** * Minimal subset of the * [File System Access](https://developer.mozilla.org/docs/Web/API/File_System_API) * `OpfsDirectoryHandle` interface that this module touches at runtime. Structurally * compatible with the DOM-lib `OpfsDirectoryHandle` — at call sites you pass real OPFS * handles directly. */ export interface OpfsDirectoryHandle { /** Discriminant: always `'directory'`. */ readonly kind: 'directory'; /** The directory's name. */ readonly name: string; /** Resolve a child file handle, optionally creating it. */ getFileHandle(name: string, options?: { create?: boolean; }): Promise; /** Resolve a child directory handle, optionally creating it. */ getDirectoryHandle(name: string, options?: { create?: boolean; }): Promise; /** Remove a child entry, optionally recursively. */ removeEntry(name: string, options?: { recursive?: boolean; }): Promise; } /** * Minimal subset of the DOM `FileSystemWritableFileStream` interface used by the OPFS battery's * main-thread write path. */ export interface OpfsWritableFileStream { /** Append/write a chunk to the stream. */ write(data: Uint8Array | ArrayBufferView | ArrayBuffer | string): Promise; /** Flush and close the stream, committing the written contents. */ close(): Promise; } /** * Minimal subset of the DOM `Blob` interface used by {@link OpfsSpoolReader} streaming-mode * random-access reads. Real OPFS handles return a `File` here; we narrow to the methods we * actually call. */ export interface OpfsBlob { /** Byte length of the blob. */ readonly size: number; /** Return a sub-range of the blob as a new blob. */ slice(start?: number, end?: number, contentType?: string): OpfsBlob; /** Read the blob's contents as text. */ text(): Promise; /** Open a readable byte stream over the blob. */ stream(): OpfsReadableStream; } /** * Minimal subset of the DOM `File` interface used by {@link OpfsSpoolReader}. */ export interface OpfsFile extends OpfsBlob { /** The file's name. */ readonly name: string; } /** * Minimal subset of the DOM `ReadableStream` interface used by streaming-mode * index construction. */ export interface OpfsReadableStream { /** Acquire a reader over the stream. */ getReader(): OpfsReadableStreamReader; } /** * Minimal subset of the DOM `ReadableStreamDefaultReader` interface used by * streaming-mode index construction. */ export interface OpfsReadableStreamReader { /** Read the next chunk, or signal end-of-stream with `done: true`. */ read(): Promise<{ done: false; value: Uint8Array; } | { done: true; value: undefined; }>; /** Release the reader's lock on the stream. */ releaseLock(): void; } /** * Constructor options for {@link OpfsSpoolReader}. */ export interface OpfsSpoolReaderOptions { /** * Byte-length threshold that switches between eager and streaming modes. * * @remarks * - Below the threshold → eager (whole-file in memory). * - At or above the threshold → streaming (line-offset index + per-line slice reads). * * Set to `0` to force streaming mode; set to `Number.POSITIVE_INFINITY` to force eager mode. * * @defaultValue `10 * 1024 * 1024` (10 MiB) */ streamThresholdBytes?: number; /** * The stable OPFS file name (key) this reader was opened from. * * @remarks * Optional. When supplied, the reader can emit a serialisable {@link @nhtio/adk!ReaderDescriptor} via * `describe()` so a {@link @nhtio/adk!SpooledArtifact} backed by it round-trips through * `encode()`/`decode()`. {@link OpfsSpoolStore} threads this in automatically; a reader constructed * from a bare file handle with no `name` is not describable (the opaque handle has no serialisable * locator) and the artifact cannot be encoded. */ name?: string; } /** * Reads an OPFS-backed file as a {@link @nhtio/adk!SpoolReader}. * * @remarks * Constructor is **not** async — but the first method call awaits a private readiness promise * that fetches the underlying `File` (and in eager mode, its contents). Subsequent calls reuse * the cached state. This keeps construction call sites synchronous while still doing real I/O * lazily. * * All four `SpoolReader` methods on this reader return promises. The `SpoolReader` contract * supports both sync and async return; consumers of `SpooledArtifact` handle either. */ export declare class OpfsSpoolReader implements SpoolReader { #private; constructor(handle: OpfsFileHandle, opts?: OpfsSpoolReaderOptions); /** * Returns `true` if `value` is an {@link OpfsSpoolReader} instance. * * @remarks * Uses {@link @nhtio/adk!isInstanceOf} for cross-realm safety. * * @param value - The value to test. * @returns `true` when `value` is an {@link OpfsSpoolReader} instance. */ static isOpfsSpoolReader(value: unknown): value is OpfsSpoolReader; line(index: number): Promise; byteLength(): Promise; lineCount(): Promise; /** * Returns the full underlying content as a single decoded string, byte-faithful to the source. * * @remarks * In **eager mode** the content is already cached at first-call load and this method is * effectively a property access. In **streaming mode** there is no cache: the file is re-read * (as a single `File.text()` call) on every invocation. Use `SpooledArtifact.asString()` * judiciously on large streaming-mode artifacts. */ readAll(): Promise; describe(): ReaderDescriptor | undefined; } /** * Constructor options for {@link OpfsSpoolStore}. */ export interface OpfsSpoolStoreOptions { /** * Optional thunk that resolves the {@link OpfsDirectoryHandle} used as the store root. * * @remarks * When omitted, the store resolves the root via `navigator.storage.getDirectory()` on its * first filesystem call. Override for tests (to point at a per-suite subdirectory) or to * scope the store to a nested directory inside OPFS. * * The thunk is invoked at most once per store; the returned handle is memoised. */ directory?: () => Promise; /** * Optional filename prefix prepended to every `callId`. * * @remarks * Prefix is a **filename prefix**, not a subdirectory — `keyPrefix: 'agent-runs/'` produces * a file literally named `agent-runs/` at the root, not a nested directory. (OPFS * filenames may not contain `/`, so use a non-`/` separator like `-` if you want a flat * namespace.) This mirrors the `keyPrefix` semantics in the flydrive and in-memory batteries. * * @defaultValue `""` */ keyPrefix?: string; /** * Default `streamThresholdBytes` for readers produced by `write()` and `read()`. Individual * calls may override via their own `opts` argument. * * @defaultValue `10 * 1024 * 1024` (10 MiB) */ streamThresholdBytes?: number; } /** * "Give bytes, get a reader" persistence layer over an OPFS directory. * * @remarks * `write(callId, bytes)` resolves the root directory (lazily, on first call), opens or creates * the file named `keyPrefix + callId`, then writes via the API matching the current scope: * a `FileSystemSyncAccessHandle` in worker scopes, `OpfsFileHandle.createWritable()` on * the main thread. A fresh {@link OpfsSpoolReader} pointed at the same file is returned. * * `read(callId)` returns a reader without re-writing; `delete(callId)` removes the entry. * * The store is otherwise stateless — it owns no in-memory cache of writes. Multiple * `OpfsSpoolStore` instances sharing the same root directory and key prefix see the same data. * * @example * ```ts * import { OpfsSpoolStore } from '@nhtio/adk/batteries/storage/opfs' * * const store = new OpfsSpoolStore({ keyPrefix: 'agent-runs/' }) * * const bytes = await tool.executor(ctx)(args) * const reader = await store.write(callId, bytes) * const Ctor = tool.artifactConstructor?.() ?? SpooledArtifact * const artifact = new Ctor(reader) * ``` */ export declare class OpfsSpoolStore implements SpoolStore { #private; constructor(opts?: OpfsSpoolStoreOptions); /** * Returns `true` if `value` is an {@link OpfsSpoolStore} instance. * * @remarks * Uses {@link @nhtio/adk!isInstanceOf} for cross-realm safety. * * @param value - The value to test. * @returns `true` when `value` is an {@link OpfsSpoolStore} instance. */ static isOpfsSpoolStore(value: unknown): value is OpfsSpoolStore; /** * Persists `bytes` under `callId` and returns a reader bound to the stored key. * * @remarks * `string` input is encoded as UTF-8; `Uint8Array` is stored byte-faithfully; * `ReadableStream` is written incrementally — the stream is consumed chunk-by-chunk * straight to OPFS without first materializing the whole payload in memory, which is the point * of accepting a stream for a durable store. * * @param callId - Identifier used to retrieve the bytes via {@link OpfsSpoolStore.read}. * @param bytes - The bytes to store, as a `string`, `Uint8Array`, or `ReadableStream`. * @param opts - Per-call override for `streamThresholdBytes`. * @returns An {@link OpfsSpoolReader} over the stored bytes. */ write(callId: string, bytes: string | Uint8Array | ReadableStream, opts?: OpfsSpoolReaderOptions): Promise; /** * Returns a reader over the bytes previously written under `callId`. * * @remarks * Returns `undefined` if the file does not exist. * * @param callId - Identifier supplied to a prior {@link OpfsSpoolStore.write} call. * @param opts - Per-call override for `streamThresholdBytes`. * @returns An {@link OpfsSpoolReader}, or `undefined` if the key is missing. */ read(callId: string, opts?: OpfsSpoolReaderOptions): Promise; /** * Removes the entry under `callId`. * * @param callId - Identifier whose entry should be removed. * @returns `true` if the entry existed and was removed; `false` if it didn't exist. */ delete(callId: string): Promise; /** * Returns `true` if a file is present under `callId`. * * @param callId - Identifier to test. * @returns `true` when the file exists, `false` otherwise. */ has(callId: string): Promise; /** * Returns the full filename for a given `callId` (i.e. `keyPrefix + callId`). * * @remarks * Useful for tests or for callers that want to interact with the underlying OPFS directory * directly. */ keyFor(callId: string): string; }