@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
155 lines • 8.24 kB
TypeScript
import type { ChunkOptions } from '../rag/chunk.js';
import type { VectorStore } from '../rag/vector-store.js';
import type { EmbeddingProvider } from '../spi/embedding-provider.js';
import { type UploadCompletePayload } from './media-events.js';
import { type TextExtractor } from './text-extractor.js';
/** The minimal, structural slice of an `@adonis-agora/media` disk this bridge reads bytes from. */
export interface DiskBytesReader {
/** Return the object's raw bytes (a media disk's `getBytes(key)`). */
getBytes(key: string): Promise<Uint8Array>;
}
/**
* The minimal, structural slice of an `@adonis-agora/media` `MediaManager` this bridge needs — just
* `disk(name)` yielding a {@link DiskBytesReader}. Structural typing keeps `@adonis-agora/media` an
* OPTIONAL peer: nothing is imported from it, an app passes its live `MediaManager` (or any handle that
* satisfies this shape) at wiring time.
*/
export interface MediaManagerHandle {
disk(name?: string): DiskBytesReader;
}
/**
* A media file to ingest. `id` becomes the RAG document id (chunk ids `${id}#<n>`); `disk`/`key` locate
* the bytes through the {@link MediaManagerHandle}; `contentType` drives extractor dispatch. The owner /
* tenant fields are optional and, when present, tag every chunk's metadata so retrieval can filter by
* tenant/owner/collection.
*/
export interface MediaRef {
id: string;
disk: string;
key: string;
contentType: string;
ownerType?: string;
ownerId?: string;
collection?: string;
tenantRef?: string;
/** Byte size, when known up front — used only by the `maxBytes` gate; the bytes are the source of truth. */
size?: number;
}
export type MediaIngestSkipReason = 'unsupported-type' | 'too-large' | 'empty-text';
export type MediaIngestResult = {
status: 'ingested';
chunks: number;
} | {
status: 'skipped';
reason: MediaIngestSkipReason;
};
/** Turn an `upload.complete` diagnostics payload into an ingestable {@link MediaRef}, or `null` to skip. */
export type ResolveMediaRef = (payload: UploadCompletePayload) => MediaRef | null | Promise<MediaRef | null>;
/**
* Everything the media→RAG bridge needs. Only `media`, `embedder`, and `store` are required; the
* extractor defaults to {@link defaultTextExtractor} (text/*, JSON, HTML) so binary formats are skipped
* rather than indexed as garbage. Register a PDF/DOCX parser on a {@link import('./text-extractor.js').MimeTextExtractor}
* and pass it as `extractor` — the PDF library stays entirely on the host side (never a dependency here).
*/
export interface MediaRagIngestionConfig {
/** The media handle bytes are read through (an `@adonis-agora/media` `MediaManager`, structurally typed). */
media: MediaManagerHandle;
/** Embeds each chunk. Pass the same {@link EmbeddingProvider} the agent's retriever uses. */
embedder: EmbeddingProvider;
/** The RAG vector store chunks are upserted into (the same store the retriever searches). */
store: VectorStore;
/** Bytes → text. Default {@link defaultTextExtractor}. Register a PDF/DOCX hook to widen it. */
extractor?: TextExtractor;
/**
* Restrict ingestion to these content types (checked before the extractor). Omit/empty = every type
* the extractor supports. A content type not in this allow-list is skipped as `unsupported-type`.
*/
contentTypes?: string[];
/** Chunking options forwarded to `chunkDocuments`. */
chunk?: ChunkOptions;
/** Skip (don't read) files larger than this many bytes (checked against `ref.size` when present). */
maxBytes?: number;
/**
* Resolve an `upload.complete` diagnostics payload (`{ id, disk, key }`) into a full {@link MediaRef}
* — the seam the auto-subscriber needs, since the event carries no content-type or owner metadata.
* Required to call {@link MediaRagIngestion.subscribe}; return `null` to skip an event.
*/
resolve?: ResolveMediaRef;
}
/** Thrown by {@link MediaRagIngestion.subscribe} when no `resolve` seam was configured. */
export declare class MediaRagResolveRequiredError extends Error {
constructor();
}
/**
* Bridges `@adonis-agora/media` uploads into the agent's RAG stack: fetch the bytes → extract text by
* content type → chunk → embed → upsert into the configured {@link VectorStore}, tagging every chunk
* with `{ mediaId, ownerType, ownerId, collection, tenantRef }` so retrieval can be scoped per
* tenant/owner/collection. Two triggers, both opt-in and no-op until wired:
*
* - {@link ingestMedia} — call it explicitly with a {@link MediaRef} (e.g. from your own upload flow).
* - {@link subscribe} — subscribe to the media library's `agora:media:upload.complete` diagnostics
* channel and auto-ingest each finished upload (needs a `resolve` seam).
*
* Everything is structural: no import of `@adonis-agora/media`, and `node:diagnostics_channel` is a
* Node builtin, so there is no hard dependency on `@adonis-agora/diagnostics` for the subscribe path.
*/
export declare class MediaRagIngestion {
private readonly config;
private readonly extractor;
private readonly contentTypes;
private readonly inFlight;
private teardown;
constructor(config: MediaRagIngestionConfig);
/**
* Ingest one media file: content-type filter → size gate → read bytes → extract → remove-then-chunk
* → embed → upsert. Unsupported/oversized/empty files are skipped (not errors). Chunk metadata carries
* `{ mediaId, ownerType, ownerId, collection, tenantRef }` (only the fields present on `ref`).
*/
ingestMedia(ref: MediaRef): Promise<MediaIngestResult>;
/** Drop a media document's chunks from the vector store — the delete half of keeping RAG in sync. */
removeMedia(mediaId: string): Promise<void>;
/**
* Guard + resolve + ingest one `upload.complete` diagnostics payload. Public so a caller can await it
* directly (deterministic tests) instead of going through the subscription. A no-op when `resolve`
* isn't configured, the payload isn't a valid `upload.complete`, or `resolve` returns `null`.
*/
handleUploadComplete(payload: unknown): Promise<MediaIngestResult | undefined>;
/**
* Subscribe to the media library's `agora:media:upload.complete` channel and auto-ingest each finished
* upload. Idempotent (a second call is a no-op while already subscribed). Returns — and stores — an
* unsubscribe function; ingestion errors are caught and published on `agora:rag:media.failed` so a bad
* file never breaks the channel. Requires a `resolve` seam ({@link MediaRagResolveRequiredError}).
*/
subscribe(): () => void;
/** Unsubscribe from the media channel (if subscribed) and await every in-flight auto-ingestion. */
unsubscribe(): Promise<void>;
/** Await every in-flight auto-ingestion — for graceful shutdown and deterministic tests. */
settle(): Promise<void>;
/** Whether the auto-subscriber is currently attached to the media channel. */
get subscribed(): boolean;
/** Handle one channel event with the same error boundary the subscriber uses. */
private ingestFromEvent;
private track;
}
/**
* Build a media→RAG ingestion bridge (idiomatic factory). Wire it in a provider's `boot()`:
*
* ```ts
* import { mediaRagIngestion } from '@adonis-agora/agent/rag-media'
*
* const ingestion = mediaRagIngestion({
* media: await app.container.make('media.manager'),
* embedder: myEmbedder,
* store: myVectorStore,
* contentTypes: ['text/plain', 'text/markdown', 'application/pdf'],
* extractor: defaultTextExtractor().register('application/pdf', myPdfExtractor),
* resolve: async ({ id, disk, key }) => lookupMediaRecord(id), // → MediaRef
* })
* const off = ingestion.subscribe() // auto-ingest on upload.complete
* ```
*
* Or trigger it explicitly: `await ingestion.ingestMedia(ref)`. Everything is opt-in and no-op until
* wired — nothing runs, subscribes, or imports the media/pdf peers unless you call it.
*/
export declare function mediaRagIngestion(config: MediaRagIngestionConfig): MediaRagIngestion;
//# sourceMappingURL=media-rag-ingestion.d.ts.map