/** * Transcript reducer — the ONE event → conversation state machine. * * `pinecall run` has three observers of the same agent events: the terminal * live view (src/cli/live-view.ts), the console server's CallsModel * (src/cli/console/calls-model.ts) and the web console in the browser. They * must never disagree about what was said, so none of them owns the logic: * they all feed the store below and render what comes out. * * store.feed(agentId, "user.speaking", [{ text }, call]) * │ * ├── updates the CallSnapshot (the state anybody can read) * └── emits effects: "a caller line was finalised", "the draft moved", * "a tool ran", "the session ended" — one per thing a renderer * would draw, in the order it should draw them. * * The semantics are exactly the ones the terminal view shipped with: * * - `user.speaking` is interim: it REPLACES the caller draft; `user.message` * fixes it as a line. * - on voice, `bot.speaking` may carry the whole reply up front but nothing * is shown until `bot.word` plays it — the line is what has been HEARD. * - chat and WhatsApp have no audio and no words, so there `bot.speaking` IS * the line: chunks are coalesced as they stream and fixed after a short * settle (or on the next event). * - a session that never announced itself (`pinecall chat`, the MCP chat * tool, any llm.chat client) gets an implicit context on its first event. * - `bot.interrupted` closes the line with a cut marker. * * Dependency-free and side-effect-free at import time on purpose: the browser * console imports this very file through a Vite alias, so it must not reach * for node:*, for the SDK's Agent type, or for anything at module scope. * Timers are injectable for the same reason (and so tests can drive them). * * Importable as `@pinecall/sdk/console`, or by relative path from the repo. */ type TranscriptChannel = "phone" | "webrtc" | "chat" | "whatsapp" | "unknown"; type TranscriptState = "ringing" | "listening" | "thinking" | "pause" | "speaking" | "ended"; /** One finalised thing in the conversation. */ interface TranscriptLine { who: "caller" | "agent" | "tool"; /** What was said — for a tool line, a short rendering of the call. */ text: string; at: number; final: boolean; /** The agent was cut off mid-utterance (bot.interrupted). */ cut?: boolean; tool?: { name: string; args?: unknown; result?: unknown; }; } /** A call/session as the console shows it. Plain JSON — it goes over HTTP as-is. */ interface CallSnapshot { id: string; agent: string; channel: TranscriptChannel; direction?: string; peer?: string; startedAt: number; endedAt?: number; durationS?: number; reason?: string; state: TranscriptState; lines: TranscriptLine[]; /** What is in flight right now: the caller's interim words, the agent's growing line. */ draft: { caller?: string; agent?: string; }; } /** The slice of a Call the reducer reads (matches the SDK's Call and the SSE payloads). */ interface TranscriptCall { id: string; from?: string; to?: string; direction?: string; transport?: string; duration?: number; } type TranscriptEffect = { kind: "session.started"; agent: string; call: CallSnapshot; implicit: boolean; } | { kind: "session.ended"; agent: string; call: CallSnapshot; reason: string; durationS: number; } | { kind: "ringing"; agent: string; from?: string; to?: string; } | { kind: "caller.line"; agent: string; call: CallSnapshot; text: string; } | { kind: "agent.line"; agent: string; call: CallSnapshot; text: string; cut: boolean; } | { kind: "tool.call"; agent: string; call?: CallSnapshot; name: string; args: unknown; } | { kind: "tool.result"; agent: string; call?: CallSnapshot; name?: string; result: unknown; } /** The draft or the turn state moved — redraw the live line / the open bubble. */ | { kind: "draft"; agent: string; call: CallSnapshot; } | { kind: "wa.message"; agent: string; who: string; text: string; } | { kind: "wa.response"; agent: string; text: string; source?: string; }; /** Every event the store subscribes to — one list, so no observer can drift. */ declare const TRANSCRIPT_EVENTS: readonly ["call.started", "call.ended", "call.ringing", "chat.started", "whatsapp.started", "speech.started", "user.speaking", "user.message", "eager.turn", "turn.end", "turn.pause", "turn.resumed", "turn.continued", "bot.speaking", "bot.word", "bot.finished", "bot.interrupted", "llm.toolCall", "whatsapp.message", "whatsapp.response"]; type TranscriptEventName = (typeof TRANSCRIPT_EVENTS)[number]; interface TranscriptTimers { set(fn: () => void, ms: number): unknown; clear(handle: unknown): void; } interface TranscriptStoreOptions { /** Milliseconds clock — injectable for tests. Default Date.now. */ clock?: () => number; /** * How long a text reply (chat, or a session whose channel is unknown) waits * for more chunks — or for `bot.word`s that would make it a voice line — * before it is fixed as the agent line. Default 300 ms. */ settleMs?: number; /** How many ended sessions to keep. Default 50. */ keepEnded?: number; /** setTimeout/clearTimeout, injectable so this module imports nothing. */ timers?: TranscriptTimers; } /** The slice of Agent the store subscribes to — structural, so no SDK import. */ interface TranscriptEmitter { id: string; on(event: string, handler: (...args: any[]) => void): unknown; off(event: string, handler: (...args: any[]) => void): unknown; } interface TranscriptStore { /** Subscribe to an agent's events. Idempotent per id. Returns the matching detach. */ attach(agent: TranscriptEmitter): () => void; /** Feed one agent event, with the handler's raw arguments. */ feed(agentId: string, name: string, args: unknown[]): void; /** * A tool returned. Tool results are not an event — the SDK auto-executes * tools — so the runner's execute wrapper hands them in here. */ toolResult(agentId: string, call: TranscriptCall | undefined, result: unknown): void; /** Drop everything an agent owns (pending timers included). */ detach(agentId: string): void; /** Subscribe to effects. Returns the unsubscribe. */ on(listener: (effect: TranscriptEffect) => void): () => void; /** Sessions in flight, by id — insertion ordered. */ readonly live: ReadonlyMap; /** Live + the last `keepEnded` ended sessions, newest first. */ snapshots(): CallSnapshot[]; get(id: string): CallSnapshot | undefined; /** Release every pending timer (process exit). */ dispose(): void; } declare function createTranscriptStore(opts?: TranscriptStoreOptions): TranscriptStore; /** Map the SDK's transport to the console's channel vocabulary. */ declare function toChannel(transport: string | undefined): TranscriptChannel; /** Merge a streamed chunk: servers send deltas (append) or the growing text so far (replace). */ declare function mergeChunk(cur: string, text: string): string; /** Append a spoken word with single-space joining. */ declare function appendWord(line: string, word: string): string; /** Tool arguments arrive as a JSON string on the wire; older shapes may already be objects. */ declare function parseArgs(args: unknown): unknown; /** * CallsModel — the console's read model over the shared transcript store. * * The third observer of a `pinecall run` process (after the terminal live view * and anything the developer wires with `pc.stream()`): it holds no logic of * its own, it reads the SAME `TranscriptStore` the terminal renders from, so * `GET /api/calls` and the terminal can never disagree about what was said. * * What it adds on top of the store is only what HTTP needs: a newest-first * list capped at the live calls plus the last N ended ones, lookup by id, and * hanging a live call up through the agent that owns it. */ /** The slice of Agent the model needs — structural, so tests pass fakes. */ interface CallsModelAgent { id: string; call(callId: string): { hangup(): void; } | undefined; } interface CallsModelOptions { store: TranscriptStore; agents: ReadonlyMap; /** Cap on the returned list (live + ended). Default 50. */ limit?: number; } interface CallsModel { /** Live + recently ended sessions, newest first. */ list(): CallSnapshot[]; get(id: string): CallSnapshot | undefined; /** End a live call. False when the id is unknown or already ended. */ hangup(id: string): boolean; } declare function createCallsModel(opts: CallsModelOptions): CallsModel; export { type CallSnapshot, type CallsModel, type CallsModelAgent, type CallsModelOptions, TRANSCRIPT_EVENTS, type TranscriptCall, type TranscriptChannel, type TranscriptEffect, type TranscriptEmitter, type TranscriptEventName, type TranscriptLine, type TranscriptState, type TranscriptStore, type TranscriptStoreOptions, type TranscriptTimers, appendWord, createCallsModel, createTranscriptStore, mergeChunk, parseArgs, toChannel };