packages/discord-presence-core/README.md

@clankie/discord-presence-core

Transport-neutral Discord participation. Everything here is blind to whether Clankie is wearing the official bot or the personal-lab user session, which is what lets both bodies be one character (ADR 0024, ADR 0048).

ModuleResponsibility
presence-sessionGateway/voice phase lifecycle, typed phase events, act-tool revoke fence
presence-action-advertiserRetains the live catalogue and forwards phase as an execution fence
discord-restShared bounded REST writes for both Discord transports
captain-action-controlAuthenticated local control requests from captain tools
text-ingressNormalises gateway messages into bounded, allowlist-gated captain turns, images included (ADR 0081)
room-textShared admission for typed room experience across text, voice, and game threads (ADR 0124)
voice-addressPhonetic name-mention: opens a session; the offered turn decides whether to speak (ADR 0119)
voice-floorDormant ↔ engaged floor: wake, offer (silence-ok), listen, decay, volition (ADR 0119)
realtime-sessionInjectable OpenAI/xAI realtime boundaries: transcription, conversation, and ask_clankie round trips
anthropic-voiceBounded Claude text brain, shared voice tools, streamed replies and cancellable requests
elevenlabs-ttsElevenLabs legacy TTS and explicit v4 Turbo dialogue WebSocket boundary (ADR 0070)
external-voicePairs a text brain with a TTS mouth behind the one conversation port (ADR 0070)
voice-sessionVox-backed attributed speech/text input, shared group floor, deliberate barge-in and playback
vox-gatewayStructural gateway adapter, validated OP4 voice effects and confirmed account leave
vox-processTokenless Vox startup, versioned IPC readiness and process probes
voice-compositionShared voice dependency assembly for bot and user-session bodies
voice-controlLocal join/leave control request handling
voice-musicShared bounded queue and transport controls
voice-ingressSubmits attributed ask_clankie handoffs to Clankie's bounded parallel room threads
voice-consentEphemeral consent under explicit or owner-selected presence policy; opt-out always wins
voice-audioShared voice-provider PCM helpers and content-free RMS measurement
receipt-storeAppend-only, content-free receipts for both planes

Voice receipts use the discord.voice.* vocabulary — joined, consent, utterance, text_input, floor, response, volition, overlap, interrupted, failed, left — and every field is a content-free scalar: ids, counts, durations, and typed outcomes, never transcript, prompt, audio, or PCM.

Rules

  • Never import discord.js. A bot-shaped client is a transport detail and belongs in the app that owns that transport. The bot bridge uses discord.js; the user-session bridge uses a bounded raw gateway plus fetch.
  • Derive the lane address from discordPresenceLaneAddress. It is keyed by where the conversation happens (discord:<guildId|dm>:<channelId>), never by which transport observed it. A transport-local identifier would fork one conversation into two captain lanes and split the character in half.
  • transportKind is configuration, not inference. Both ingress paths take it from their host process; neither guesses.
  • Visuals are selected here, never in a bridge. Both transports map their raw attachments and link-preview images (including gifv embeds) and call selectInboundImageAttachments, so one rule decides what he can be shown. A policy that admitted an image on one body and not the other would be two characters, not one (ADR 0081).
  • This package never fetches attachment bytes. It carries references; the clankie service resolves them at the last hop before the model.
  • The active voice room owns its attached text chat. Text-only messages in that exact guild/channel enter VoiceFloor, where the realtime room persona speaks, hands off, or stays silent. The bridge does not also launch a text captain turn (ADR 0124).
  • Voice identity stays attached to a gateway stream. Speakers use separate transcription inputs. Only attributed JSON transcript items converge into the shared engaged conversation; overlapping raw audio is never interleaved and guessed after the fact. Every finalized consented speech transcript gets a contextual model decision, including nameless requests after a pause. The model may stay silent; engagement controls interruption eligibility, not hearing (ADR 0119).
  • Speaker listeners are bounded. An inactive per-speaker transcription session closes after two minutes and reopens on demand. At 25 retained listeners, the least recently active idle listener is evicted before another opens; active captures and pending transcript correlation are never evicted. Each consented recording also ends after three minutes of elapsed time or forwarded PCM, whichever comes first. A host can set recordingLimitMs; a recording limit never grants capture consent.
  • Vox is the sole voice media owner. Both media-enabled Discord bodies use native Vox capture, TTS playback, DAVE readiness, and audible music; a text-only bot does not spawn it. This package owns policy and correlation only; ordinary leave clears Vox's primary role without closing the process or touching its stream-watch/publish roles. TTS is audible only after Vox reports started; buffered means queued and does not occupy the floor, while drained means finished PCM and trailing frames crossed the sender. Fresh app-level readiness evidence records mediaOwner: vox; the joined receipt separately proves positive role-scoped DAVE. A leave qualifies only after the account gateway confirms detachment. See ADR 0128.

Host call limits

Local and hosted callers reuse DiscordVoiceSession.announce(text, { timeoutMs }) for call warnings and endGracefully({ reason, message, timeoutMs }) for a cap or sleep transition. The status is operational context for Clankie's existing voice provider: he composes the warning or farewell using the current persona and conversation. An announcement waits for current capture and speech; it counts as spoken only after the matching Vox playback drains.

Graceful ending immediately stops new capture and normal response admission, preserves already admitted speech, and offers a brief farewell before leaving. The default deadline is 60 seconds across waiting, provider generation and playback. Silence, interrupted output or timeout returns incomplete; an incomplete ending also emits voice_graceful_end_incomplete. The account gateway must still confirm detachment before the caller releases its stay or returns Vox to a pool. Neither model completion nor the core's local left receipt proves a Discord account leave. Billing, tenant limits and wake policy remain the host's.

Private briefing and tool callbacks may outlast their original authority. The session captures the admitted lease and checks its fresh guard/current state after each awaited briefing, handoff or self-tool result, before opening or seeding a provider conversation or submitting a tool result. The host must also recheck the original actor and room after its body RPC. Revocation discards the late local result; it does not undo or replay an already admitted body action. VoiceBodyAdmission.guard(actorId?) and current(actorId?) accept the captured initiator. Initial openings use the attributed speaker or admitted initiating actor; later room turns keep their own speaker. The optional native conversation admission guard/current hooks run after socket acquisition and before private session configuration, closing a denied connected socket without sending it persona instructions. Existing callers without these hooks keep their behavior.

DiscordVoxGatewayBridge accepts a structural gateway adapter and has no Discord client dependency. startOfficialBotVox({ enabled: true, env }) starts the same tokenless media process and checks its versioned IPC handshake. The official bot app retains its old import paths as reexports; hosted runtimes import these helpers from this package.

Consumers

Voice latency evidence

Voice capture commits after 500ms without input audio. Transcription streams before that boundary, but replies wait for a final transcript. Captures that never reach 80 RMS (s16 full scale 32,768) are marked filtered and send no provider audio or commit; a bounded 200ms lead-in preserves quiet word onsets when speech arrives. This near-silence filter is separate from the unchanged 1,200 RMS, transcript-confirmed interruption guard.

transcription.latencyMs includes speaking time; captureEndToFinalMs measures finalization and lastAudioToFinalMs also includes endpoint delay. Response receipts retain input timing through wake and tool handoffs: lastAudioToFirstAudioMs, captureEndToFirstAudioMs, transcriptToFirstAudioMs, and transcriptToRequestMs. The existing toFirstAudioMs starts at the individual response request. External voice also reports requestToFirstTextMs; requestToFirstAudioChunkMs and firstAudioChunkToPlaybackMs distinguish synthesis delivery from playback. Missing measurements are omitted, not zero. Input PCM arrival and transmitted playback are transport evidence, not measurements of phoneme or headphone time.

Voice receipts allow up to 32 scalar, content-free fields so correlation IDs, token counts, and timings survive together. Older 16-field writers could lose response receipts even when Vox logged a successful start and drain.

Chaotic group calls

Voice ingress keeps each ask in its own visible handoff. Clankie's service admits up to four room handoffs concurrently, at most two per room, with at most 32 waiting jobs. Excess asks get a busy result. Different speakers' work can progress in parallel. The realtime conversation and local voice tools keep running while work waits. Tool results retain their recipient, and the mouth gives that person the gist, expanding when warranted. Speech responses serialize through provider completion and, for external voices, TTS drain. The executor follows the live head; every non-owner under a Codex head uses Pi with their original room grant. Only the verified owner can use native Codex children. See ADR 0229.

A realtime server error can abandon the current local response attempt as failed, without fabricating provider completion or usage. An unidentified bare error does not establish which provider response caused it. Abandonment stops that attempt's partial playback, and the next eligible offer can proceed; failed lines are never replayed. Uncertain errors before response.created, and uncorrelatable ID-less xAI output after abandonment, close the existing conversation. The next eligible offer uses the normal lazy reopen path. Correlated delayed response events cannot settle a newer offer. Live provider compatibility remains unproven.

The floor retains up to five recently engaged speakers for 60 seconds each. Their unnamed follow-ups are offers Clankie may decline; unrelated chatter is offered for contextual judgment without forcing a response. Typed-input volition caps, consent, and machine grants are unchanged. See ADRs 0091 and 0119.

Voice playback paces synthesized PCM in 100ms chunks with at most one second of real-time lead before sending it to Vox. Provider completion waits for the local queue to empty before finish_tts_playback; stop, failure, leave, and timeout discard queued audio. Vox retains its fail-closed 15-second buffer cap.

New speech replaces unheard replies across the room: bursts during opening collapse to one opportunity, provider/TTS queues drop stale response requests, and queued PCM is discarded before playback. A superseded or interrupted reply still generating gets response.cancel, and the external mouth closes its TTS context and releases its held done, so the next reply never waits on dead speech. Tool results stay in context; in-flight work retains its actor. Explicit “stop talking” cuts playback on the final transcript even below the ordinary barge-in loudness gate; from a recently engaged or addressing speaker it also drops queued speech and keeps late handoff results silent. A recently engaged speaker who talks over him for 700 ms of speech-level audio, in a capture begun after his reply became audible, stops him without waiting for the transcript (ADR 0057, 2026-10-04). Unaddressed speech waits for a pause before it becomes a response opportunity: while another participant is still talking or their final is due, the latest such line waits (bounded at 8 seconds) and is offered once when the room pauses.

An exact call-ID retry joins its original pending work. Identical wording in a new call remains a separate ask. ask_clankie.join_call_id joins only that authenticated speaker's handoff in the same realtime conversation. Slow work offers one brief acknowledgment after 1.2 seconds, canceled if the room moves on or work finishes. Spoken length and register come from the service-composed instructions (one rule: usually a short sentence, longer only when asked); handoff results get the gist in a sentence with the rest offered in text: at most 1,500 characters of a captain answer reach the voice model, and an answer arriving 30 seconds or more late, after the room moved on, is offered rather than forced. Spoken YouTube searches list three hits for him to name one or two. OpenAI output is bounded to 800 audio / 200 text tokens per response, and all Discord mouths have a 45-second PCM ceiling. These are runaway backstops, not the length control; live taste and transcription latency still require a call.

Opt-in voice transcripts include Clankie's generated wording from native audio transcripts or external TTS text, correlated with item/playback ids and outcomes. subscribeSpokenTranscript is separate from the consented human listener, so output cannot masquerade as room input. Without a subscriber the session does not accumulate output text. Interrupted/failed/truncated entries may include an unheard ending; suppressed entries never played. The private transcript store and authenticated API retain these labels; content-free receipts never carry text.

Transcript readers incrementally index newline offsets and read only the requested page. Existing line-number cursors stay valid; an unfinished tail waits for its newline. File replacement or truncation rebuilds the index. Receipt writers combine concurrent appends into batches of at most 64 records; each promise resolves only after the batch is flushed to disk. The pending queue is bounded at 1,024 records and rejects overload.