/** * The studio chat recorder (DES-decision-capture §4.3.3, DC-S4b; DOC-4: the pair framing). * * A chat seat is asked (the scope statement's `## Decisions` section, `chat-scope.ts`) to end every * reply with a fenced `wicked-decisions` JSON block labelling the OPERATOR'S words of the turn it is * answering. This module is the whole host: * * - {@link splitDecisionsBlock} / {@link DecisionFenceFilter}: the block never reaches a reader. The * `chatReply` text is cut before persist and broadcast (`ChatTranscriptStore.rewriteEvent`), and the * streamed `chatDelta` frames hold back a tail that could still become the fence, then drop * everything from the fence on until the turn's reply (review S4 / N8). * - {@link ChatDecisionRecorder}: the recorder is chosen deterministically from the turn's audience * (the first `claude` seat, else the first seat in roster order — review N3); its items become * records when their quote is found, whitespace-normalised, in the SAME turn's operator message * (the model may label words, never invent them); every other seat's items are `labels.votes`. * - It fails OPEN with no regex fallback: a missing or malformed block leaves the turn * "unclassified" (a counter on `/diagnostics`) and the operator's words are STILL recorded, so the * deterministic derivation (`derive.ts`) runs with no model at all. The model only adds offers. * - DOC-4: an item that `approves_proposal` carries `decision_text` written from the seat's approved * proposal; that text becomes the record's statement and the record names the proposal (the * previous turn, the recorder seat). A bare "lets do it" therefore yields a decision whose text is * the proposal's, never auto (`derive.ts` refuses auto for an approval). * * Words enter the ledger only through `ingestDecision` (§4.3.1): the actor is the one who SENT the * message (`noteSend`, from the route), so an agent token's chat message records nothing. */ import type { ChatTranscriptStore } from '../api/chat-transcripts.js'; import type { Actor, CoreEvent, DiagnosticsDecisions, SteeringType } from '../core/types.js'; import type { DecisionService } from './land.js'; import type { DecisionType } from './types.js'; /** The opening fence, exactly as the codebook (`skills/mem/refs/decision-report.md`) spells it. */ export declare const DECISIONS_FENCE = "```wicked-decisions"; /** One labelled span, as the codebook defines it. `none` is crew's label, never an item. */ export interface DecisionReportItem { quote: string; decision_text?: string; type: Exclude; codify: boolean; ambiguous: boolean; steering_type: SteeringType; approves_proposal: boolean; same_as: string | null; } export interface ParsedDecisionsBlock { items: DecisionReportItem[]; /** Items the block carried that did not parse (dropped one by one; the block still counts). */ dropped: number; } /** * Parse a block body. `null` when the JSON is malformed or the body is not `{items: [...]}`; an item * that fails its own shape is dropped and counted, the rest survive. */ export declare function parseDecisionsBlock(body: string): ParsedDecisionsBlock | null; /** * Cut the LAST fenced `wicked-decisions` block out of a reply. The opening fence must be a line of * its own (leading indentation allowed — a list item or a quoted block is still a block; a mention of * the fence inside a sentence is prose, codex r1). An opening fence with no closing fence — a reply * cut off mid-block — is removed from its line on and reported `truncated`; nothing of a block ever * reaches a reader. */ export declare function splitDecisionsBlock(text: string): { text: string; block: string | null; truncated: boolean; }; /** * The `chatDelta` filter: per (chat, seat), hold back the longest tail that is still a prefix of the * fence (so a fence split across frames is caught), and from the fence on drop every character * until {@link reset} (the turn's `chatReply`, which carries the clean full text anyway). */ export declare class DecisionFenceFilter { private readonly state; private static key; delta(chat: string, cliKey: string, text: string): string; reset(chat: string, cliKey: string): void; } /** The recorder of a turn: the first `claude` seat of its audience, else the first seat (review N3). */ export declare function pickRecorder(seats: ReadonlyArray): string | undefined; export interface ChatRecorderDeps { service: DecisionService; transcripts: ChatTranscriptStore; broadcast: (frame: CoreEvent) => void; projectOf: (chatId: string) => string | undefined; now?: () => number; /** A turn whose seats never all answer is closed after this (default: the turn index's stale budget). */ staleAfterMs?: number; /** How long `closed()` waits for the chat's recordings in flight before the close proceeds * (default 15 s). A recording that outlives it is FENCED: it writes nothing to the chat's * transcript or /ws (the chat's epoch moved), only the ledger under the project it captured. */ closeDrainMs?: number; /** DC-S7: every finalized turn is handed on (the Consideration + its fact), before the words are judged. */ onTurnRecorded?: (chat: string, turnId: string) => Promise; log?: (msg: string) => void; } export interface ChatReplyInput { chat: string; cliKey: string; turnId?: string; ok: boolean; /** The block body `ChatTranscriptStore.rewriteEvent` cut out of the reply, or `null`. */ block: string | null; /** `failed`: a `chatSessionFailed` frame ended the seat's part with no reply (not counted). */ kind?: 'reply' | 'failed'; } export declare class ChatDecisionRecorder { private readonly deps; private readonly turns; private readonly lastTurn; private readonly counters; private unclassified; private recorded; private readonly now; private readonly staleAfterMs; private readonly inFlight; /** Per chat: the recordings in flight (a sweep's or a reply's), so a close can drain them. */ private readonly inFlightByChat; /** Per chat: bumped by `closed()` — a recording started under an older epoch writes nothing * to the transcript or /ws (the id may belong to a newer conversation by then). */ private readonly epochs; private readonly closeDrainMs; /** (chat, turnId) → replies that arrived BEFORE the route recorded the send (ASK-C2: the PA can * answer while the route still awaits the engine's acceptance). Replayed by `noteSend`. */ private readonly early; constructor(deps: ChatRecorderDeps); private static key; private log; private counter; /** The route recorded a send: who said it (the actor), to whom (the audience the engine reached). */ noteSend(chat: string, turnId: string, actor: Actor, text: string, seats: ReadonlyArray): void; /** ASK-C2: the turn's audience as the engine settled it (a random pick's PA, a re-pick) — the * recorder is chosen from THIS set, so a turn reserved for the eligible roster records the * PA's block, not a seat that never answered. */ reconcile(chat: string, turnId: string, seats: readonly string[]): void; /** A seat ended its part of a turn. Resolves once any recording it triggered has settled. */ onReply(input: ChatReplyInput): Promise; private fold; /** The chat is gone: whatever its open turns gathered is recorded now. */ closed(chat: string): Promise; private track; /** Resolves once every recording in flight has settled (tests, shutdown). */ idle(): Promise; diagnostics(): DiagnosticsDecisions; private sweep; private finalize; private record; } //# sourceMappingURL=chat-recorder.d.ts.map