/** * Which surface does an assistant turn belong to — typed chat, or live voice? * * Shared by every product, for the same reason the transport is: voice was built * as an overlay over the chat thread in the first product that needed it, so * both surfaces write into ONE conversation. Without a marker there is no way to * tell a spoken turn from a typed one once it is stored, and every voice * exchange shows up in the typed thread. * * One marker fixes both directions: * - the chat panel hides turns that happened in voice * - the voice panel shows ONLY turns that happened in voice * * ★ Deliberately a DISPLAY split, not separate persistence. It survives a reload * because the chat transcript is never rehydrated from the backend — the store is * the only source of truth for what either panel shows. The backend session stays * shared, so the model keeps full context across both surfaces, which is what * makes "carry on where we left off" work. Genuinely separate backend sessions * are a larger change and would cost that. * * ★ Product-agnostic by construction: it knows about turns and surfaces, never * about crops, workflows or nodes. Nothing here needs to change for a product * whose modes are `fluidgrids-node` rather than `api-calls`. */ interface TurnLike { metadata?: ({ voice?: boolean; voiceSessionId?: string; } & Record) | undefined; } /** True when this turn happened inside a live voice session. */ export declare function isVoiceTurn(message: TurnLike): boolean; /** * WHICH voice session produced this turn, if we know. * * ★ `undefined` is a real answer, not a failure: every spoken turn recorded * before session ids existed carries `voice: true` and nothing else, and those * conversations are in users' localStorage today. It means "some earlier * session" and must never be read as "not spoken" — `isVoiceTurn` remains the * only thing that decides which surface a turn belongs to. */ export declare function voiceSessionIdOf(message: TurnLike): string | undefined; /** * The marker as a fragment to spread into a message's `metadata`. * * ★ `patchMessage` REPLACES `metadata` instead of merging it, so every write * that rebuilds the object has to re-assert this or the turn silently demotes * itself to a chat turn. That is exactly what shipped: the marker was stamped * at creation and destroyed the instant the reply completed, so a spoken answer * appeared in the typed thread AND disappeared from the voice transcript at the * same moment — the transcript could only ever show the user's questions. * * Spreading one helper at all four sites is what keeps them from drifting; * `voiceMarkerCallSitesAreComplete` in the test file fails the build if a new * metadata write forgets it. * * ★ Takes the SESSION ID rather than a boolean, and stamps both fields from it. * There used to be a separate `dictated` flag that call sites passed straight * through, which made it possible to mark a turn as spoken while recording no * session — a state with no meaning that only the transcript would ever notice. * One argument means the two can never disagree. * * An absent id yields an EMPTY object, so a typed turn gets no marker at all: * `chatTurns` is allow-by-default, and a stray `voice: false` would be a second * way of saying the same thing. */ export declare function voiceMarker(sessionId: string | undefined): { voice?: true; voiceSessionId?: string; }; /** * The turns the CHAT panel should show: everything that was not spoken. * * Untagged turns are treated as chat, so every conversation recorded before the * marker existed keeps rendering exactly as it did. */ export declare function chatTurns(messages: readonly T[]): T[]; /** * The turns the VOICE panel should show: only what was spoken. * * This is also what scopes the voice transcript to the session. Previously it * took the last six messages of the whole conversation, so opening voice on an * existing thread immediately painted typed replies — the wall of raw markdown * in the operator's screenshot. */ export declare function voiceTurnsOnly(messages: readonly T[]): T[]; /** One microphone-on-to-End, and the turns it produced, oldest first. */ export interface VoiceSessionGroup { /** * `undefined` for turns recorded before session ids existed. Rendered as one * leading group rather than dropped — it is real history the user spoke. */ sessionId: string | undefined; turns: T[]; } /** * Split spoken turns into the sessions that produced them. * * The transcript shows a conversation's whole spoken history, which is the more * useful of the two options — you can go back to what you asked last time — but * run together it reads as one long exchange, and a question from a session * three days ago looks like it was just asked. Grouping is what makes showing * everything honest. * * ★ Groups by RUNS, not by collecting every turn with the same id. Turns are * stored in the order they happened, so a run IS a session; gathering by id * instead would silently reorder history if two sessions ever interleaved, and * would hide it rather than show it if they did. * * ★ Consecutive un-stamped turns collapse into a single leading group. Treating * each as its own session would print a divider between every pre-existing turn * — one long exchange shattered into fragments, which is worse than the * undivided version this is meant to fix. */ export declare function groupBySession(turns: readonly T[]): VoiceSessionGroup[]; /** * The same grouping for turns that have already been flattened for display. * * The transcript renders `{id, role, text}`, not `Message`, so it cannot reach * `metadata`. Passing the extractor keeps ONE implementation of the run logic * instead of a second copy that drifts — the subtle part is the `undefined` * merge, and having it in two places is how one of them stops doing it. */ export declare function groupConsecutiveBySession(turns: readonly T[], sessionIdOf: (turn: T) => string | undefined): VoiceSessionGroup[]; /** * A spoken turn flattened for display. * * ★ Shared by every component between the chat area and the transcript, so the * session id is carried in the TYPE and not merely at runtime. The intermediate * props used to declare `{id, role, text}` while the objects flowing through * happened to have more: it compiled, and the dividers worked, purely because * JavaScript keeps the extra property. Any future refactor that rebuilt one of * those objects would have dropped the id and silently run every session back * together — the exact defect this field was added to fix, reintroduced with no * type error. */ export interface VoiceTranscriptTurn { id: string; role: 'user' | 'assistant'; text: string; /** * When the turn happened, ISO. * * Carried for the SAVED transcript rather than for the on-screen one: a file * that outlives the session is read later, when "when did I ask that?" is the * actual question, and a record without times answers it badly. */ timestamp: string; /** * `undefined` on turns recorded before session ids existed. * * ★ REQUIRED-but-nullable rather than optional, and the difference is the * whole point. With `sessionId?: string`, an object rebuilt without the key is * still structurally assignable — so a refactor that mapped turns through and * dropped it would compile, and every session would silently run back * together. Written this way, omitting the key is a type error and passing * `undefined` is a decision someone made on purpose. */ sessionId: string | undefined; } export {}; //# sourceMappingURL=turnVisibility.d.ts.map