/** * The minimal synchronous SQLite handle the store needs — structurally the same * surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local * so the store depends on a shape, not on the runtime package. (Identical to the * S2 presence store's `SqliteDb`.) */ export interface SqliteDb { /** Execute one or more statements with no result (DDL, migrations). */ exec(sql: string): void; /** Run a parameterised statement, returning the changed-row count. */ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint; }; /** Run a parameterised query, returning all rows as plain objects. */ all>(sql: string, params?: unknown[]): T[]; } /** A monotonic wall clock, injectable for deterministic tests. */ export interface Clock { now(): number; } /** The default clock: `Date.now()`. */ export declare const systemClock: Clock; /** * A stream's retention lifecycle. `ephemeral` transcripts are flushed once on job * completion and retained until a retention sweep; `long-lived` transcripts grow * incrementally and are bounded by a rolling offset window. */ export type TranscriptLifecycle = "ephemeral" | "long-lived"; /** A stream's transcript status. */ export type TranscriptStatus = "open" | "completed"; /** A single durable transcript chunk and the offset it was assigned. */ export interface TranscriptChunk { readonly offset: number; readonly chunk: string; } /** * A structured turn's author role — the additive turn-structured view's parity * with Camunda `AgentHistoryRole` (issue #475). One pass through the agent loop * (model reasons → selects tools → evaluates results) is recorded as one or more * role-tagged turns sharing a `loopIteration`. */ export type TranscriptTurnRole = "USER" | "ASSISTANT" | "TOOL_RESULT" | "CONFIGURATION" | "UNSPECIFIED"; /** Parity with Camunda `AgentHistoryContentType`: the type of a content block. */ export type TranscriptContentType = "TEXT" | "DOCUMENT" | "OBJECT" | "UNSPECIFIED"; /** * A single typed content block in a turn's message, mirroring Camunda's * `AgentHistoryMessageContentValue`. Exactly one payload is populated per the * `contentType`: `text` for TEXT, `documentReference` for DOCUMENT, `object` * (any JSON value) for OBJECT. */ export interface TranscriptContentBlock { readonly contentType: TranscriptContentType; /** Text payload; populated when `contentType` is TEXT. */ readonly text?: string; /** Document reference; populated when `contentType` is DOCUMENT. */ readonly documentReference?: string; /** JSON value payload; populated when `contentType` is OBJECT (any JSON type). */ readonly object?: unknown; } /** * A tool call embedded in a turn, mirroring Camunda's * `AgentHistoryEmbeddedToolCallValue`: `toolCallId`, `toolName`, the tool task's * `elementId`, and the `arguments` passed to it. */ export interface TranscriptToolCall { readonly toolCallId: string; readonly toolName: string; readonly elementId?: string; readonly arguments: Readonly>; } /** * Per-turn metrics, mirroring Camunda's `AgentHistoryMetricsValue`: the token * counts consumed/produced by the turn's LLM call and its wall-clock duration. */ export interface TranscriptTurnMetrics { readonly inputTokens: number; readonly outputTokens: number; readonly reasoningTokenCount: number; readonly cacheCreationTokenCount: number; readonly cacheReadTokenCount: number; readonly durationMs: number; } /** * A structured transcript turn — the additive Camunda `AgentHistoryRecordValue` * parity view (issue #475). `sequence` is the stream-local append order and the * idempotency key (mirroring a chunk's `offset`); `loopIteration` is the * agent-loop turn counter carried as data (several role-split turns can share one * iteration). Recording turns never touches the raw chunk stream. */ export interface TranscriptTurn { /** Stream-local append order + idempotency key (like a chunk's `offset`). */ readonly sequence: number; /** The agent-loop turn counter (Camunda `loopIteration`). */ readonly loopIteration: number; readonly role: TranscriptTurnRole; readonly content: readonly TranscriptContentBlock[]; readonly toolCalls: readonly TranscriptToolCall[]; /** Per-turn metrics; undefined when the worker reported none for this turn. */ readonly metrics?: TranscriptTurnMetrics; /** Epoch-millis timestamp the turn was produced; undefined when unreported. */ readonly producedAt?: number; } /** Per-stream transcript metadata. */ export interface TranscriptStream { readonly stream: string; readonly lifecycle: TranscriptLifecycle; readonly status: TranscriptStatus; /** When the stream was first opened, ISO-8601. */ readonly createdAt: string; /** When an ephemeral run was flushed & completed, ISO-8601 (undefined while open). */ readonly completedAt?: string; /** The oldest retained offset, or undefined when the transcript is empty. */ readonly firstOffset?: number; /** One past the highest offset ever recorded (the resume high-water mark). */ readonly nextOffset: number; /** * Total UTF-8 byte size of the currently *retained* chunk payloads — the sum of * `utf8ByteLength(chunk)` over exactly the chunks {@link TranscriptStore.read} * would return. Tracks the retained window (drops on eviction / sweep), so a * consumer can render a per-stream size without reading the payloads back. */ readonly byteLength: number; /** * Count of currently *retained* chunks — `read(stream).length`. Because * retention evicts the head and `nextOffset` is a high-water mark (not a count), * this cannot be derived from the offset window and is tracked from the chunks. */ readonly chunkCount: number; } /** * The result of a {@link TranscriptStore.since} reattach query — the same shape * the S5 {@link ReplayRing.since} returns, now served from durable storage. */ export interface TranscriptSlice { /** The retained chunks with `offset >= from`, in offset order. */ readonly entries: readonly TranscriptChunk[]; /** * `true` when `from` predates the oldest retained offset: chunks the consumer * asked for were already dropped by retention (rolling window / expiry), so the * replay is a best-effort resume, not gap-free from `from`. */ readonly gap: boolean; /** One past the highest recorded offset (where the live stream continues). */ readonly nextOffset: number; } /** * The minimal resume-from-offset source a {@link TranscriptStore.flush} reads. * The S5 {@link ReplayRing} satisfies this structurally (`since(0).entries` is the * whole retained window; `nextOffset` is its high-water mark), so the store can * flush a real relay ring without a compile dependency on the relay package. */ export interface TranscriptRing { since(from: number): { readonly entries: readonly TranscriptChunk[]; }; readonly nextOffset: number; } export interface TranscriptStoreOptions { /** * How long a *completed ephemeral* transcript is retained after its * `completed_at` before {@link TranscriptStore.sweep} may drop it, in ms. * Default 86_400_000 (24h). Long-lived streams are never time-swept. */ ephemeralRetentionMs?: number; /** Injectable clock for deterministic tests. Default {@link systemClock}. */ clock?: Clock; } /** * Raised when a transcript row read back from storage holds a value outside its * domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a * bad manual write. Fail fast rather than silently coercing to a default, which * would mask the corruption and skew retention decisions. */ export declare class TranscriptCorruptionError extends Error { constructor(message: string); } /** * Raised when an operation targets a lifecycle it does not apply to (e.g. * completing a `long-lived` stream, which by definition never completes). */ export declare class TranscriptLifecycleError extends Error { readonly stream: string; constructor(stream: string, message: string); } export declare class TranscriptStore { #private; constructor(db: SqliteDb, options?: TranscriptStoreOptions); /** The completed-ephemeral retention window in ms. */ get ephemeralRetentionMs(): number; /** * Apply the canonical transcript DDL (idempotent). Callers that let the app * DataLayer migration runner apply the transcript migrations * (`db/migrations/002_agentic_transcript.sql` for the chunk stream and * `db/migrations/008_agentic_transcript_turns.sql` for the turn-structured * view) do not need this — but it is provided so the store is usable against a * bare source too. The DDL is identical to the migrations (drift-guarded). */ ensureSchema(): void; /** * Open (or fetch) a stream's transcript with the given lifecycle. Idempotent: * a first call stamps `created_at` and the lifecycle; later calls return the * existing row unchanged (lifecycle is first-wins and never mutates). Returns * the stored metadata row. */ open(stream: string, lifecycle: TranscriptLifecycle): TranscriptStream; /** * Record chunks into a stream's durable transcript, idempotently. Each chunk is * keyed `(stream, offset)`, so re-recording an already-stored offset (a retry, a * re-flush, an overlapping reattach) is a no-op — never a duplicate. Auto-opens * the stream with `lifecycle` (default `long-lived`) if it is not open yet; if the * stream already exists under a different lifecycle this throws a * {@link TranscriptLifecycleError} before writing anything (lifecycle is * first-wins), so a mismatched flush cannot leave a partial write. * The batch is atomic: if any entry has an invalid offset (or a write fails) * partway through, the whole call rolls back — it records every chunk or none. * Returns the number of newly-persisted chunks. * * This is the incremental path a long-lived stream uses; {@link flush} builds on * it for the ephemeral completion path. */ record(stream: string, entries: Iterable, lifecycle?: TranscriptLifecycle): number; /** * Flush a resume-from-offset source (an S5 {@link ReplayRing}) into a stream's * durable transcript. Persists the source's entire retained window * (`source.since(0)`) idempotently and advances the stream's high-water mark to * `source.nextOffset` (so the recorded `nextOffset` reflects everything ever * produced, even chunks the ring already evicted). Returns the number of * newly-persisted chunks. * * For an `ephemeral` stream this is the job-completion flush: it also marks the * transcript `completed` (stamping `completed_at`), after which {@link read} * yields the durable transcript and {@link sweep} may later retire it. For a * `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`. */ flush(stream: string, source: TranscriptRing, lifecycle: TranscriptLifecycle): number; /** * Reattach a consumer from offset `from` (inclusive). Returns the retained * chunks with `offset >= from`, the live `nextOffset`, and a `gap` flag when * `from` predates the oldest retained offset (retention dropped chunks the * consumer wanted). Mirrors the S5 {@link ReplayRing.since} contract exactly, * so a reattach behaves identically whether it resumes from the live ring or * the durable transcript. */ since(stream: string, from: number): TranscriptSlice; /** Read a stream's whole durable transcript in offset order. */ read(stream: string): TranscriptChunk[]; /** * Record structured turns into a stream's additive turn-structured view — the * Camunda `AgentHistoryRecordValue` parity layer (issue #475). Each turn is * keyed `(stream, sequence)` so re-recording an already-stored sequence (a * retry, a re-emit, an overlapping reattach) is a no-op — never a duplicate, * exactly the idempotency the chunk stream gets from `(stream, offset)`. * * This is purely additive: it never reads or writes the raw chunk stream or the * stream's offset window, so it cannot regress any existing chunk reader. It * auto-opens the stream (default `long-lived`) so the turns hang off a stream * row; a lifecycle mismatch throws a {@link TranscriptLifecycleError} before * writing anything (lifecycle is first-wins). The batch is atomic: an invalid * turn (or any failed write) partway through rolls the whole call back — it * records every turn or none. Returns the number of newly-persisted turns. */ recordTurns(stream: string, turns: Iterable, lifecycle?: TranscriptLifecycle): number; /** Read a stream's whole turn-structured transcript in `sequence` order. */ readTurns(stream: string): TranscriptTurn[]; /** * Apply a rolling retention window to a long-lived stream: drop every chunk with * `offset < before`. A subsequent {@link since} from an offset older than * `before` reports a `gap`. Returns the number of chunks dropped. Refuses to * truncate an `ephemeral` transcript (those are retained whole until swept) with * a {@link TranscriptLifecycleError}. */ truncateBefore(stream: string, before: number): number; /** * Retention sweep for completed ephemeral transcripts: drop every stream whose * `status = 'completed'` and whose `completed_at` is older than the retention * window, along with its chunks and structured turns. Long-lived streams are * never time-swept (they are bounded by {@link truncateBefore} instead). Returns * the removed stream ids. * * The selection and all deletes run inside a single SAVEPOINT (#atomic) so the * sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls * back, so the DB is never left partially swept and the returned list always * matches what was actually deleted. */ sweep(now?: number): string[]; /** Look up a single stream's transcript metadata. */ get(stream: string): TranscriptStream | undefined; /** Every stream's metadata, ordered by first open then stream id. */ list(): TranscriptStream[]; /** Number of tracked streams. */ count(): number; }