/** * CallLogView — THE reducer (CALL_LOG_SPEC.md §6). * * "Client SDKs maintain ONE reducer (log → {phase, messages, toolCalls, * turns, metrics}) fed by any pipe, deduped by seq." * * This is the whole point of the module: WS attach, WebRTC DataChannel, GET * polling and replay are four pipes carrying one envelope, and they must * land on one piece of state-building code. A second reducer would be a * second vocabulary in disguise. * * ── Semantics ──────────────────────────────────────────────────────────── * Ported from the proven `VoiceSession.handleDataChannelMessage` switch * (@pinecall/web, src/core/VoiceSession.ts:303-502) — word reassembly, * `mergeUserTurn` interim merging, tool-argument parsing — with four * deliberate corrections: * * 1. NO transport coupling and no `trackedTools` filter. The view exposes * every tool call; a UI that wants a subset filters at render time. * Filtering during reduction makes the state depend on widget config. * 2. NO `disconnect()` back-edge. VoiceSession called `this.disconnect()` * from inside the switch (:418) — a reducer reaching into a socket. * Here, terminal facts produce an *intent* on the state; the owner of * the transport decides what to do about it. * 3. Duration is derived from entry `ts`, never from wall clock, so a * replay of a finished call reproduces the same state as watching it * live (§10.5). * 4. `phase` gains "ended", and `bot.corrected` REPLACES the text of the * entry it supersedes (§2) rather than appending a second bubble. * * ── Idempotence and order independence ─────────────────────────────────── * `apply()` is idempotent by `seq` and independent of arrival order. It is * not a naive running fold: entries are retained in a seq-keyed map and the * state is the fold over them in seq order. Applying an entry newer than * everything seen (the live case) folds incrementally in O(1); an * out-of-order or backfilled entry rebuilds, which is what correctness * costs and what makes in-order === shuffled === resumed. * * ── Immutability ───────────────────────────────────────────────────────── * State is produced by structural sharing, never by mutation: an apply * yields a new state object, new arrays, and new objects for exactly the * entries it changed. Reference equality on a message therefore MEANS "this * line did not change" — the contract a memoized transcript line depends on. */ type CallPhase = "idle" | "ringing" | "listening" | "thinking" | "speaking" | "ended"; type MessageRole = "user" | "bot" | "system"; /** One transcript bubble. `seq` is the entry that created it — `bot.corrected.supersedes` points here. */ interface CallMessage { /** The seq of the entry that created this message. Stable identity. */ seq: number; role: MessageRole; text: string; /** Provider message id (`user.message.id`, `bot.speaking.id`, …). */ id?: string; /** True while a non-final `user.message` is the latest word on this turn. */ interim?: boolean; /** True between `bot.speaking` and `bot.finished`/`bot.interrupted`. */ speaking?: boolean; interrupted?: boolean; /** Set on the system bubble that mirrors a `tool.call`. */ toolCallId?: string; /** Word alignment when TTS provided it (`bot.speaking.words`). */ words?: WordTiming[]; /** True once a `bot.corrected` entry replaced this text. */ corrected?: boolean; } interface CallToolCall { id: string; name: string; args: Record; /** seq of the `tool.call` entry. */ seq: number; /** Present once the correlated `tool.result` arrives. */ result?: unknown; ms?: number; error?: string; done: boolean; } interface CallTurn { turn: number; role?: TurnRole; latency?: TurnLatency; startedAt?: number; endedAt?: number; } interface CallMetrics { /** Rolled-up distributions, present once `call.summary` lands. */ summary?: CallSummaryData["metrics"]; cost?: number; recordingUrl?: string; /** Mean end-to-end latency over the `turn.end` entries seen so far. */ e2eMean?: number; turnCount: number; } /** * Something the log says should happen to the transport, surfaced instead of * done. Correction #2 above: the reducer never touches a socket. */ interface CallIntent { kind: "disconnect"; reason: string; seq: number; } /** * One row of `state.custom`: the latest value per `(name, id)`, in first-seen * order. The wire stays append-only — every `call.log()` is its own entry * with its own seq — the upsert is a projection of this reducer only. */ interface CallCustomEntry { name: string; /** `data.id ?? String(seq)` — the upsert key together with `name`. */ id: string; value: V; /** seq of the entry that LAST set this value. */ seq: number; ts: number; /** Server-stamped turn id, when the session has turns. */ turn?: number; } interface CallLogState { phase: CallPhase; messages: CallMessage[]; toolCalls: CallToolCall[]; turns: CallTurn[]; metrics: CallMetrics; /** False once `call.ended` is applied. */ live: boolean; /** Highest seq applied. The cursor to resume from (`after=`). */ lastSeq: number; /** Call id, from the first entry that carried one. */ call: string | null; agent: string | null; /** Seconds, derived from entry `ts` — never from wall clock. */ duration: number; /** True after `log.caught_up`; never inferred from contiguity (§1). */ caughtUp: boolean; /** Declared gaps (§3). Never silently papered over. */ gaps: { from: number; resumeFrom: number; }[]; userSpeaking: boolean; botSpeaking: boolean; /** Reason from `call.ended`, if any. */ endedReason?: string; /** Human takeover state (`handoff.*`). */ handoff: "none" | "requested" | "active"; /** Skills currently loaded (`skill.loaded` / `skill.unloaded`). */ skills: string[]; /** Latest RAG citations (`docs.sources`). */ sources: unknown[]; /** Things the log asked the transport to do (§ correction #2). */ intents: CallIntent[]; /** Durable `custom` entries, upserted by (name, id). Ephemeral ones never land here. */ custom: CallCustomEntry[]; } /** * What a `log.gap` carries in `data.snapshot` — the consolidated state of * everything the server could still see when it declared the gap, so a * client lands with a populated view instead of an empty one (§3, ag-ui * "For Pinecall" 5 and 9). This is the wire contract between * `calls_api._snapshot()` and `CallLogView`: the server emits exactly these * keys, the reducer hydrates exactly these fields. * * Rows reuse the reducer's own row types, so hydration is a keyed merge * rather than a second fold: `messages` by `seq` (bot bubbles by `id`), * `tool_calls` by `id`, `turns` by `turn`, `custom` by `(name, id)`. * Scalars are values — the snapshot's word wins. Every key is optional: a * missing key leaves the local state untouched, and an unknown key is * ignored (§1 forward compatibility). * * Not carried, by design: `metrics.summary`/`cost` (only `call.summary` sets * them, and it is never skipped — a sealed cursor answers 204), `intents` * (transport asks, not call facts) and the `log.*` control state. */ interface LogGapSnapshot { phase?: CallPhase; live?: boolean; /** ts of `call.started` — the duration anchor. */ started_at?: number | null; ended_reason?: string; user_speaking?: boolean; bot_speaking?: boolean; handoff?: CallLogState["handoff"]; skills?: string[]; sources?: unknown[]; messages?: CallMessage[]; tool_calls?: CallToolCall[]; turns?: CallTurn[]; custom?: CallCustomEntry[]; } /** * Project a state into the snapshot a `log.gap` would carry for it — the * inverse of hydration. The golden test feeds `snapshotOf(prefix)` into a * gap and asserts the result equals the full replay; a server that wants * to mint gaps against a TS reducer can use it directly. */ declare function snapshotOf(state: Readonly, startedAt?: number | null): LogGapSnapshot; /** * A single log view. Feed it entries from any pipe, in any order, as many * times as you like; read `state`. */ declare class CallLogView { #private; private readonly retain; /** * How many entries to retain for out-of-order rebuilds. The retained * window bounds memory on a long call; entries older than the window are * still reflected in the state, they just cannot be re-folded. Default * 10_000 (the hot buffer is 1000, so this is generous by 10x). */ constructor(retain?: number); /** * Read-only snapshot, safe to retain and to compare by reference. * * Every state-affecting apply produces a new state object, new * `messages`/`toolCalls`/`turns`/`custom` arrays, and new objects for exactly the * entries that changed — untouched siblings keep their identity. So * `prev.messages[3] === next.messages[3]` is a truthful "this line did * not change", which is what makes a memoized transcript line correct * rather than merely fast. */ get state(): Readonly; /** The cursor to resume from: `?after=`. */ get lastSeq(): number; /** Subscribe to state changes. Returns an unsubscribe function. */ subscribe(fn: (state: CallLogState) => void): () => void; /** The entries this view retains, in seq order — the resume payload. */ entries(): KnownLogEntry[]; /** True if this exact seq has already been applied. */ has(seq: number): boolean; /** * Apply one entry. Idempotent by `seq`, order-independent. * Returns true if the view changed. */ apply(entry: AnyLogEntry): boolean; /** Apply many. Returns how many changed the view. */ applyAll(entries: readonly AnyLogEntry[]): number; /** Drop everything and start over. */ reset(): void; } /** Build a view from a batch of entries. */ declare function createCallLogView(entries?: readonly AnyLogEntry[]): CallLogView; /** * The Call Log — envelope + closed vocabulary (CALL_LOG_SPEC.md §1, §2). * * ───────────────────────────────────────────────────────────────────────── * WIRE SHAPE. This module speaks the WIRE, verbatim. * * Envelope keys are exactly the seven of spec §1 (`seq`, `ts`, `call`, * `agent`, `type`, `ephemeral`, `data`) and every key INSIDE `data` is * snake_case, exactly as the server appends it. There is deliberately NO * codec in the path: the server emits an envelope, the browser applies that * same envelope, and `GET /v1/calls/{id}/events` returns the same bytes * during the call and after it (spec §10.4). A camelCase translation layer * would make "identical" a claim about a transform rather than about bytes. * * `src/protocol/events.ts` (camelCase, legacy SDK surface) is a DIFFERENT, * frozen vocabulary and stays untouched — see spec §8. * ───────────────────────────────────────────────────────────────────────── * * ZERO DEPENDENCIES. Nothing under `src/log/**` imports anything outside * itself. The `@pinecall/sdk` root entrypoint pulls in `ws` and node * builtins; the `./log` subpath must be usable from a browser bundle, so * the isolation is enforced by a test (`tests/log-browser-safe.test.ts`). * * FORWARD COMPATIBILITY. Unknown `type`s MUST be ignored (§1). The unions * below are closed for what a consumer may *rely* on, not for what may * arrive: `AnyLogEntry` therefore admits an unknown-type arm, and the * reducer's switch is exhaustive over the known arms. */ /** Every fact a session produces becomes exactly one of these. */ interface LogEntry> { /** * Monotonic per call, assigned only at the append point. The cursor AND * the dedupe key. May have holes after compaction — never assume * contiguity; "caught up" is signalled by `log.caught_up`, never inferred. */ seq: number; /** Server wall clock, float seconds. */ ts: number; /** Call id. `null` on the agent's lifecycle-only log (§2, "The agent log"). */ call: string | null; /** Agent id. */ agent: string; /** One vocabulary (§2). No per-channel dialects. */ type: T; /** `true` → delivered live, never persisted (§4). */ ephemeral: boolean; /** Type-specific payload (§2). Additive-only per type. */ data: D; } type CallDirection = "inbound" | "outbound"; /** `call.ringing` — outbound: exists before pickup. */ interface CallRingingData { direction: CallDirection; from: string; to: string; } /** `call.started` — `metadata` is the sealed token metadata. */ interface CallStartedData { direction: CallDirection; from: string; to: string; channel: string; metadata?: Record; } /** `call.ended` */ interface CallEndedData { reason: string; duration: number; } /** One latency distribution inside `call.summary`. */ interface MetricDistribution { p50: number; p90: number; p95: number; max: number; n: number; } interface CallSummaryMetrics { e2e: MetricDistribution; asr: MetricDistribution; llm_ttft: MetricDistribution; tts_ttfb: MetricDistribution; } /** `call.summary` — ALWAYS the last entry. History needs no second API. */ interface CallSummaryData { metrics: CallSummaryMetrics; cost?: number; reason: string; /** Recordings are referenced, never embedded (§8). */ recording_url?: string; } /** `user.speaking` — ephemeral. */ interface UserSpeakingData { active: boolean; } /** `user.message` — partials ephemeral, finals persisted. */ interface UserMessageData { id: string; text: string; final: boolean; language?: string; } /** One word of TTS alignment, carried INSIDE `bot.speaking`. */ interface WordTiming { w: string; t0: number; t1: number; } /** `bot.speaking` — word alignment inside the event when TTS provides it. */ interface BotSpeakingData { id: string; text: string; words?: WordTiming[]; } /** `bot.word` — ephemeral; live typing effect only. */ interface BotWordData { id: string; w: string; } /** `bot.finished` */ interface BotFinishedData { id: string; } /** `bot.interrupted` */ interface BotInterruptedData { id: string; at_word?: number; } /** * `bot.corrected` — the transcript self-heals. An EVENT, not a mutation: * consumers replace the text of the entry named by `supersedes`. */ interface BotCorrectedData { supersedes: number; id: string; text: string; } type TurnRole = "user" | "bot"; /** `turn.start` */ interface TurnStartData { turn: number; role: TurnRole; } /** Per-turn latency is first-class. */ interface TurnLatency { vad: number; asr: number; eou: number; llm_ttft: number; tts_ttfb: number; e2e: number; } /** `turn.end` */ interface TurnEndData { turn: number; latency: TurnLatency; } /** `tool.call` — reaches EVERY audience, correlated with `tool.result` by `id`. */ interface ToolCallData { id: string; name: string; /** Providers send either a parsed object or a JSON string. Both are legal. */ args: Record | string; } /** `tool.result` */ interface ToolResultData { id: string; name: string; result: unknown; ms: number; error?: string; } /** `docs.sources` — RAG citations. */ interface DocsSourcesData { sources: unknown[]; } /** `skill.loaded` / `skill.unloaded` */ interface SkillData { skill: string; by: string; } /** `audio.metrics` — ephemeral; rolled up into `call.summary`. */ interface AudioMetricsData { mos?: number; jitter?: number; loss?: number; [k: string]: unknown; } /** `handoff.requested` / `handoff.active` / `handoff.released` */ interface HandoffData { by: string; } /** `supervisor.said` / `supervisor.whispered` — audit trail of the §7 verbs. */ interface SupervisorData { text: string; by: string; } /** * `log.gap` (§3, anti-Slack rule) — a gap is DECLARED, never silently * papered over. `snapshot` is consolidated call state so the consumer can * render immediately and continue from `resume_from`. */ interface LogGapData { from: number; resume_from: number; /** Consolidated state — see `LogGapSnapshot` in view.ts for the exact keys. */ snapshot?: LogGapSnapshot; } /** `log.caught_up` (§5) — backlog drained, live entries follow. */ interface LogCaughtUpData { seq: number; } /** * `custom` — the one open extension point: `call.log(name, value)`. The * reducer never interprets `value`; it projects the latest value per * `(name, id)` into `state.custom` (upsert — the wire itself stays * append-only). Ephemeral ones are fanned out live and never stored. */ interface CustomData { name: string; value: unknown; /** Upsert key in the projection; absent → the entry's seq. */ id?: string; /** Server-stamped turn id, when the session has turns. */ turn?: number; } /** * The complete vocabulary. A fact that does not fit one of these is a * finding to report, not a new type to mint. */ interface LogDataMap { "call.ringing": CallRingingData; "call.started": CallStartedData; "call.ended": CallEndedData; "call.summary": CallSummaryData; "user.speaking": UserSpeakingData; "user.message": UserMessageData; "bot.speaking": BotSpeakingData; "bot.word": BotWordData; "bot.finished": BotFinishedData; "bot.interrupted": BotInterruptedData; "bot.corrected": BotCorrectedData; "turn.start": TurnStartData; "turn.end": TurnEndData; "tool.call": ToolCallData; "tool.result": ToolResultData; "docs.sources": DocsSourcesData; "skill.loaded": SkillData; "skill.unloaded": SkillData; "audio.metrics": AudioMetricsData; "handoff.requested": HandoffData; "handoff.active": HandoffData; "handoff.released": HandoffData; "supervisor.said": SupervisorData; "supervisor.whispered": SupervisorData; "log.gap": LogGapData; "log.caught_up": LogCaughtUpData; "custom": CustomData; } /** Every legal `type` value. Closed — see §2. */ type LogEventType = keyof LogDataMap; /** The payload that belongs to a given `type`. */ type LogData = LogDataMap[T]; /** The discriminated union of all known entries — what the reducer switches on. */ type KnownLogEntry = { [T in LogEventType]: LogEntry; }[LogEventType]; /** * An entry as it arrives off the wire: either a known one, or one whose * `type` this SDK version has never heard of. §1 requires the latter be * ignored rather than rejected, so it is part of the input type. */ type UnknownLogEntry = Omit, "type"> & { type: string; }; type AnyLogEntry = KnownLogEntry | UnknownLogEntry; /** The set of `type` values this build understands. */ declare const LOG_EVENT_TYPES: readonly LogEventType[]; /** Narrow an off-the-wire entry to the known vocabulary. */ declare function isKnownLogEntry(entry: AnyLogEntry): entry is KnownLogEntry; /** * Structural check for the §1 envelope. Anything that fails this is not a * log entry and must not be fed to a view. */ declare function isLogEntry(value: unknown): value is AnyLogEntry; export { type AnyLogEntry, type AudioMetricsData, type BotCorrectedData, type BotFinishedData, type BotInterruptedData, type BotSpeakingData, type BotWordData, type CallCustomEntry, type CallDirection, type CallEndedData, type CallIntent, type CallLogState, CallLogView, type CallMessage, type CallMetrics, type CallPhase, type CallRingingData, type CallStartedData, type CallSummaryData, type CallSummaryMetrics, type CallToolCall, type CallTurn, type CustomData, type DocsSourcesData, type HandoffData, type KnownLogEntry, LOG_EVENT_TYPES, type LogCaughtUpData, type LogData, type LogDataMap, type LogEntry, type LogEventType, type LogGapData, type LogGapSnapshot, type MessageRole, type MetricDistribution, type SkillData, type SupervisorData, type ToolCallData, type ToolResultData, type TurnEndData, type TurnLatency, type TurnRole, type TurnStartData, type UnknownLogEntry, type UserMessageData, type UserSpeakingData, type WordTiming, createCallLogView, isKnownLogEntry, isLogEntry, snapshotOf };