/** * The transcript EVENT vocabulary + the single derive() fold (ADR 0056, #251). * * This is the "event-sourced session" layer over the S6 transcript store ({@link ./store.ts}). The * store is already append-only and offset-keyed — chunks are appended, never mutated — which is half of * the event-sourced-session pattern. The gap it left is that chunks are opaque `TEXT`: every richer * view (structured message history, tool cards, per-turn boundaries, token accounting) had to re-parse * the raw frame bytes ad hoc, a DRIFT SURFACE (two parsers of the same bytes), which our "Derivation * Over Duplication" doctrine forbids. * * This module closes that gap: the append-only log of TYPED events is the single source of truth, and * every higher-level view is a DERIVATION of that one log via a single {@link deriveView} fold — "the * log IS the state, so divergence is structurally impossible". A raw terminal chunk is retained * verbatim as a `stream-chunk` event (byte-level replay fidelity is preserved); a producer that emits a * structured, marker-tagged JSON envelope is decoded into the authoritative typed events (message / * tool-call / tool-result / turn / step / lifecycle) the derived views fold over. * * THE ONE PARSER. {@link parseTranscriptEvent} is the SINGLE place a stored chunk is classified into a * typed event; every consumer (cockpit, search, token accounting, export) reads the derived view, not * the raw bytes. A drift-guard test (`events.drift.test.ts`) asserts the event marker — and therefore * the raw→event parse — appears in exactly this module, so a second parser cannot creep in. * * MERGE-EXTENSIBLE. The vocabulary is a small core ({@link CORE_TRANSCRIPT_VOCAB}) authors extend in the * same schema with {@link mergeTranscriptVocab}, so a new event kind is an additive merge, never a fork * of the parser. A downstream app (e.g. nano-workforce#559) registers its own `permission` kind this * way without editing this package. * * BROWSER-SAFE. This module is imported by cockpit code that runs in the BROWSER (the cockpit derive), * so it takes no hard dependency on Node's `Buffer` or any Node-only API — {@link utf8ByteLength} uses * the Web/Node standard `TextEncoder`. It is pure and side-effect-free: no I/O, and it never touches the * engine or a BPMN flow (ADR 0056: app-tier only, advisory). */ export declare function utf8ByteLength(text: string): number; /** * The reserved marker field that distinguishes a structured transcript-event envelope from raw * terminal bytes. A stored chunk is decoded as a typed event ONLY when it is a JSON object carrying * this field set to the schema version — otherwise it is retained verbatim as a raw `stream-chunk`, so * a raw ANSI frame that happens to be valid JSON is never mis-classified. Namespaced so it cannot * collide with a producer's own payload keys. This is the canonical single source of truth for the * whole package family — consumers (e.g. the cockpit) import this identifier, never a private copy. */ export declare const TRANSCRIPT_EVENT_MARKER: "nwfTranscriptEvent"; /** The current transcript-event envelope schema version (the value {@link TRANSCRIPT_EVENT_MARKER} carries). */ export declare const TRANSCRIPT_EVENT_VERSION: 1; /** The core, closed set of typed transcript-event kinds. Downstream apps register extra *envelope* * kinds via {@link mergeTranscriptVocab}, but those decoders must still return one of these core * variants — this union itself does not grow for TypeScript consumers. */ export type TranscriptEventKind = "stream-chunk" | "message" | "tool-call" | "tool-result" | "turn" | "step" | "lifecycle" | "permission"; /** The message roles the derived history distinguishes (assistant is authoritative for derivation). */ export type TranscriptRole = "assistant" | "user" | "system" | "tool"; /** Fields every typed event carries: the store offset it was decoded from. */ interface TranscriptEventBase { readonly offset: number; } /** A raw terminal chunk retained verbatim for byte-level replay fidelity (the default classification). */ export interface StreamChunkEvent extends TranscriptEventBase { readonly kind: "stream-chunk"; /** The exact stored bytes — unmodified, so raw-byte replay stays faithful. */ readonly chunk: string; } /** * How a {@link MessageEvent}'s `text` relates to the display block it belongs to. This is the * delta-versus-snapshot contract the ordered display derivation ({@link deriveDisplay}) honours: * * - `"delta"` (the default when `mode` is omitted) — `text` is the NEXT fragment of a growing message; * the display fold CONCATENATES it exactly onto the block's accumulated text (no injected space, * trim or rewrite). * - `"snapshot"` — `text` is the FULL cumulative text of the message so far; the display fold REPLACES * the block's accumulated text with it (never appends). A producer MUST NOT tag a cumulative snapshot * as a `"delta"` — that would double the text. This is the "never append a snapshot as a delta" rule. */ export type MessageMode = "delta" | "snapshot"; /** * An assistant/user/system message — authoritative for the derived message history. * * The four optional fields below are the ADDITIVE producer metadata that lets the ordered display * derivation ({@link deriveDisplay}) coalesce transport-fragmented deltas back into one display block * WITHOUT guessing. Every field is optional: a legacy producer that emits none still derives a coherent * view via the deterministic adjacent-same-speaker fallback (no timing/punctuation heuristics). The * stored/raw event and its byte-faithful replay are unchanged by any of them — they only steer the * display projection, never the raw log. */ export interface MessageEvent extends TranscriptEventBase { readonly kind: "message"; readonly role: TranscriptRole; readonly text: string; /** * The producer's stable identity for the LOGICAL message this fragment belongs to. Two message events * with the same `role` AND the same `messageId` are treated as the same display block and coalesce even * when they are NOT adjacent same-speaker deltas — provided no coalescing-breaking boundary (a tool call, * permission request, or turn boundary) closes the block between them. Those boundaries still end the * block regardless of `messageId`; a later same-`messageId` fragment after such a boundary opens a fresh * block. When omitted, the display fold falls back to coalescing adjacent same-speaker deltas (an explicit * id both enables non-adjacent grouping and, when it CHANGES, forces a new block — a distinct same-role * message stays distinct). */ readonly messageId?: string; /** Delta (append) versus snapshot (replace) semantics for `text`; see {@link MessageMode}. Defaults to `"delta"`. */ readonly mode?: MessageMode; /** * An explicit START boundary: force a NEW display block for this event even if it would otherwise * coalesce with the active same-speaker block. Lets a producer that reuses/omits a `messageId` still * signal "this begins a new message". */ readonly start?: boolean; /** * An explicit COMPLETION boundary: after folding this event, CLOSE the display block so any later * same-speaker text opens a fresh block. Marks the logical message finished. */ readonly final?: boolean; } /** A tool invocation the agent issued. */ export interface ToolCallEvent extends TranscriptEventBase { readonly kind: "tool-call"; readonly name: string; /** A stable id linking this call to its {@link ToolResultEvent}, when the producer supplies one. */ readonly callId?: string; readonly args?: unknown; } /** A tool result, paired back to its {@link ToolCallEvent} by `callId` (else the most recent open call). */ export interface ToolResultEvent extends TranscriptEventBase { readonly kind: "tool-result"; readonly callId?: string; readonly ok: boolean; readonly content?: string; } /** A turn boundary — the start of a new request/response cycle. */ export interface TurnEvent extends TranscriptEventBase { readonly kind: "turn"; /** The producer's turn index, when supplied (else derived positionally). */ readonly index?: number; } /** A step boundary within a turn (a tool loop iteration, a sub-agent hop, …). */ export interface StepEvent extends TranscriptEventBase { readonly kind: "step"; readonly label?: string; } /** A session lifecycle transition (open → completed, or an explicit exit). */ export interface LifecycleEvent extends TranscriptEventBase { readonly kind: "lifecycle"; readonly phase: "open" | "completed" | "exited"; } /** * The role's permission policy the PRODUCER tags a request with. `"escalate"` means a human must be * asked (the cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task); * `"yolo"` means the action is auto-allowed and never prompts a human. Cockpit + bridge branch on this. */ export type PermissionPolicy = "escalate" | "yolo"; /** The kind of a permission option — mirrors ACP's option kinds (allow/reject × once/always). */ export type PermissionOptionKind = "allow-once" | "allow-always" | "reject-once" | "reject-always"; /** Pure, canonical: does a permission option kind ALLOW (true) or REJECT (false) the proposed action? * The `allow-*` vs `reject-*` prefix is the single source of truth. This lives beside * {@link PermissionOptionKind} so every consumer (the cockpit render seam and the permission-escalation * bridge) derives allow/deny from ONE implementation — the two paths can never disagree on what a * chosen option means (no drift surface). */ export declare function optionKindAllows(kind: PermissionOptionKind): boolean; /** One offered permission option (ACP `options[]` member): a stable id, a label, and its kind. */ export interface PermissionOption { readonly optionId: string; readonly name: string; readonly kind: PermissionOptionKind; } /** * A permission REQUEST: the agent asks the operator to allow/deny a proposed action. The `callId` * pairs the eventual {@link PermissionResolutionEvent} back to this request (mirroring how * `tool-call`/`tool-result` pair by `callId`). */ export interface PermissionRequestEvent extends TranscriptEventBase { readonly kind: "permission"; readonly phase: "request"; /** Stable id pairing this request to its resolution. */ readonly callId: string; /** The producer-tagged policy the cockpit + bridge branch on. */ readonly policy: PermissionPolicy; /** The offered options — always at least one (the decoder rejects an empty list). */ readonly options: readonly PermissionOption[]; /** The tool the proposed action would invoke, when known. */ readonly toolName?: string; /** A short human-readable title for the prompt. */ readonly title?: string; /** A longer human-readable reason for the prompt. */ readonly reason?: string; } /** * A permission RESOLUTION: the operator's (or an auto policy's) decision, carrying the same `callId`, * the chosen `optionId`, and whether the action was `allowed`. */ export interface PermissionResolutionEvent extends TranscriptEventBase { readonly kind: "permission"; readonly phase: "resolution"; /** The `callId` of the {@link PermissionRequestEvent} this resolves. */ readonly callId: string; /** The chosen option's id. */ readonly optionId: string; /** True = allowed, false = denied. */ readonly allowed: boolean; /** Provenance of the decision, when supplied. */ readonly by?: "operator" | "auto"; } /** The core, closed typed transcript-event union. Merging vocab lets an app decode custom *envelope* * kinds, but each decoder returns one of these variants — the union itself does not grow for consumers. */ export type TranscriptEvent = StreamChunkEvent | MessageEvent | ToolCallEvent | ToolResultEvent | TurnEvent | StepEvent | LifecycleEvent | PermissionRequestEvent | PermissionResolutionEvent; /** A stored chunk as the store/read path exposes it (mirrors `TranscriptChunk`). */ export interface StoredChunk { readonly offset: number; readonly chunk: string; } /** * A decoder for one event kind: given the parsed envelope body and the chunk offset, it returns the * typed event (or `undefined` to reject a malformed envelope, which then falls back to `stream-chunk`). * A vocabulary is the map kind → decoder; {@link mergeTranscriptVocab} extends it additively. */ export type TranscriptEventDecoder = (body: Record, offset: number) => TranscriptEvent | undefined; /** A transcript-event vocabulary: the ONE registry of kind → decoder the single parser consults. */ export type TranscriptVocab = Readonly>; /** * The opinionated core vocabulary — the built-in event kinds every consumer understands out of the * box. Authors extend it in the SAME schema via {@link mergeTranscriptVocab}; they never fork the * parser. (`stream-chunk` is not decoded here — it is the fallback the parser applies to any chunk * that is not a well-formed typed envelope, so raw fidelity needs no decoder.) */ export declare const CORE_TRANSCRIPT_VOCAB: TranscriptVocab; /** The core event kinds the parser decodes from an envelope (every kind except the raw `stream-chunk` * fallback). Kept as a runtime list so the drift-guard can assert the single fold handles them all. */ export declare const CORE_TRANSCRIPT_EVENT_KINDS: readonly Exclude[]; /** * Extend a vocabulary additively: later entries win on a key clash, so an author can either register a * brand-new kind or deliberately override a core decoder. Returns a NEW frozen, NULL-PROTOTYPE vocab — * neither input is mutated — so the core stays canonical AND `kind in vocab` / `Object.keys(vocab)` * only ever see own decoders (an inherited "toString"/"constructor" key can never masquerade as one). * This is the EXTENSION POINT a downstream app uses to add its own kind (e.g. a synthetic `annotation` * kind) without editing this package: one schema, extended by merge, never a second parser. */ export declare function mergeTranscriptVocab(base: TranscriptVocab, ...extensions: TranscriptVocab[]): TranscriptVocab; /** * THE ONE PARSER. Classify a single stored chunk into a typed {@link TranscriptEvent}. * * A chunk is decoded as a structured event ONLY when it is a JSON object carrying the * {@link TRANSCRIPT_EVENT_MARKER} at the current version AND a `kind` the vocab knows AND its decoder * accepts the body. Anything else — raw terminal bytes, non-JSON, a JSON value without the marker, an * unknown kind, a decoder rejection — is retained verbatim as a `stream-chunk`, so byte-level replay * fidelity is never lost. This is the SINGLE point at which raw bytes become typed events; every view * folds over the result of this function, so there is exactly one parser of the log. */ export declare function parseTranscriptEvent(entry: StoredChunk, vocab?: TranscriptVocab): TranscriptEvent; /** * Encode a typed event into the stored-chunk wire form a structured producer appends. The inverse of * {@link parseTranscriptEvent} for every non-raw kind (a `stream-chunk` is stored as its own raw bytes, * so it is returned verbatim). Provided so producers and tests speak the one envelope grammar rather * than hand-rolling the marker — the derivation-over-duplication rule applied to the write side too. */ export declare function encodeTranscriptEvent(event: TranscriptEvent): string; /** A derived tool card: a tool-call paired with its result (result absent while the call is pending). */ export interface DerivedTool { readonly name: string; readonly callId?: string; readonly args?: unknown; readonly offset: number; readonly result?: { readonly ok: boolean; readonly content?: string; readonly offset: number; }; } /** A derived message in the folded history. */ export interface DerivedMessage { readonly role: TranscriptRole; readonly text: string; readonly offset: number; } /** * A derived permission: a permission REQUEST paired with its RESOLUTION by `callId` (resolution absent * while the request is still pending), mirroring how {@link DerivedTool} pairs a call with its result. * The cockpit and the escalation bridge read THIS — they never re-parse the log. */ export interface DerivedPermission { readonly callId: string; readonly policy: PermissionPolicy; readonly options: readonly PermissionOption[]; readonly toolName?: string; readonly title?: string; readonly reason?: string; readonly offset: number; /** The resolution, once present (pending request → `undefined`). */ readonly resolved?: { readonly allowed: boolean; readonly optionId: string; readonly by?: "operator" | "auto"; readonly offset: number; }; } /** A derived turn: the messages, tool cards and step count folded within one turn boundary. */ export interface DerivedTurn { readonly index: number; readonly startOffset: number; readonly messages: readonly DerivedMessage[]; readonly tools: readonly DerivedTool[]; readonly permissions: readonly DerivedPermission[]; readonly steps: number; } /** The single derived view every higher-level consumer reads instead of re-parsing raw bytes. */ export interface DerivedView { /** The per-turn structure (a turn is opened implicitly before the first turn event when typed content — a message, tool-call or step — precedes it; raw `stream-chunk`s alone open no turn). */ readonly turns: readonly DerivedTurn[]; /** Every message across all turns, in offset order (the flat derived history). */ readonly messages: readonly DerivedMessage[]; /** Every tool card across all turns, in offset order. */ readonly tools: readonly DerivedTool[]; /** Every permission across all turns, in offset order (each request paired to its resolution by `callId`). */ readonly permissions: readonly DerivedPermission[]; /** Total retained raw bytes (UTF-8) across `stream-chunk` events — the byte-replay fidelity accounting. */ readonly rawByteLength: number; /** Number of retained raw chunks. */ readonly rawChunkCount: number; /** The session lifecycle as the last lifecycle event reports it (defaults to `open`). */ readonly lifecycle: "open" | "completed" | "exited"; /** Number of events folded — every event in the log, including raw `stream-chunk`s. */ readonly eventCount: number; } /** * THE SINGLE FOLD. Derive every higher-level view from the typed event log — "the log IS the state". * * Folds the events (assumed in offset order — the store's append order) into per-turn structure, a flat * message history, tool cards (each call paired to its result by `callId`, else the most recent open * call), raw-byte accounting for replay fidelity, and the session lifecycle. It is a pure reduction of * one log: the cockpit, search, token accounting and export all read THIS, so there is never a second * parser of the same bytes. Typed content — a message, tool-call or step — that precedes the first * explicit `turn` event opens an implicit turn 0, so a producer that never emits turn boundaries still * derives a coherent single-turn view. Raw `stream-chunk` events alone open no turn (they only feed the * byte-replay accounting), so a log of only chunks derives zero turns. */ export declare function deriveView(events: Iterable): DerivedView; /** * Convenience: parse a run of stored chunks into typed events through {@link parseTranscriptEvent} (the * one parser) and fold them with {@link deriveView} in a single call — the entry point a consumer uses * to go from stored bytes to a derived view without ever touching a second parser. */ export declare function deriveViewFromChunks(chunks: Iterable, vocab?: TranscriptVocab): DerivedView; export {};