@openclaw/discord 2026.9.1 → 2026.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/action-runtime-api.js +1 -1
  2. package/dist/{activities-api-E-segZn6.js → activities-api-CTcGq3Hf.js} +4 -4
  3. package/dist/activities-api.js +1 -1
  4. package/dist/{allow-list-C2JuH-R0.js → allow-list-DBTtR2dz.js} +3 -29
  5. package/dist/api.js +12 -12
  6. package/dist/{approval-handler.runtime-CW5k3mSa.js → approval-handler.runtime-DUSe5fyH.js} +5 -5
  7. package/dist/{audit-B9rydv5a.js → audit-DpPfwoJM.js} +4 -4
  8. package/dist/{channel-D93LnbTE.js → channel-BHajPgwR.js} +16 -16
  9. package/dist/{channel-actions.runtime-DMFv4doE.js → channel-actions.runtime-DS8pXo0S.js} +29 -32
  10. package/dist/channel-plugin-api.js +1 -1
  11. package/dist/{channel.setup-DTL7kPOM.js → channel.setup-BWmMe1tT.js} +3 -3
  12. package/dist/command-owners-D78sAoOz.js +10 -0
  13. package/dist/{components-DmXYIQ26.js → components-BimX47Mq.js} +1 -1
  14. package/dist/{conversation-binding-route-24afmvEC.js → conversation-binding-route-DuR5NGIx.js} +1 -1
  15. package/dist/{directory-config-BD4YL7wT.js → directory-config-CD2v2Szs.js} +1 -1
  16. package/dist/directory-contract-api.js +1 -1
  17. package/dist/{directory-live-BrFVc1Ds.js → directory-live-DaJzWJ2N.js} +2 -2
  18. package/dist/{doctor-BUB41_lr.js → doctor-D4HH1inA.js} +6 -3
  19. package/dist/doctor-contract-api.js +28 -26
  20. package/dist/{handle-action.guild-admin-B0egstYq.js → handle-action.guild-admin-4We6wWQU.js} +6 -3
  21. package/dist/index.js +1 -1
  22. package/dist/{ingress-identity-BCzfPPYM.js → ingress-identity-XNmW-mZS.js} +1 -1
  23. package/dist/{message-handler-Qdw9c0j4.js → message-handler-DhgBabyU.js} +52 -65
  24. package/dist/{message-handler.preflight-Dxi2yBfR.js → message-handler.preflight-oZ7dQCd0.js} +12 -12
  25. package/dist/{message-handler.process-BYDjtxZJ.js → message-handler.process-BzCYf1pa.js} +14 -14
  26. package/dist/{message-text-BRVqYm-t.js → message-text-DK4NnBg-.js} +1 -1
  27. package/dist/{outbound-adapter-DEhthWTk.js → outbound-adapter-CtggmGh9.js} +6 -6
  28. package/dist/{outbound-components-DehyxvA4.js → outbound-components-Dn8SJWMS.js} +3 -3
  29. package/dist/{outbound-session-route-BFGRF9fE.js → outbound-session-route-Q_HEusBQ.js} +5 -6
  30. package/dist/{owner-access-yc1sX7Gx.js → owner-access-Cp8r6Ipf.js} +3 -2
  31. package/dist/{pluralkit-XmmAHoV6.js → pluralkit-BY75YcK0.js} +1 -1
  32. package/dist/{provider-session.runtime-BA2BPyZ4.js → provider-session.runtime-BL2lHBH7.js} +3 -3
  33. package/dist/{provider-DuDtKbF8.js → provider-uKmmrDM4.js} +28 -28
  34. package/dist/provider.runtime-C-IkWF2w.js +2 -0
  35. package/dist/{realtime-session.runtime-DgSbX6ay.js → realtime-session.runtime-CGYm1t_0.js} +705 -295
  36. package/dist/{resolve-allowlist-common-BJ5UZNNT.js → resolve-allowlist-common-sKIKy-cF.js} +1 -1
  37. package/dist/{resolve-channels-CYseD5Uw.js → resolve-channels-CoiMlWy0.js} +3 -3
  38. package/dist/{resolve-users-DrmhDrqm.js → resolve-users-DJpiokfW.js} +2 -2
  39. package/dist/{retry-BNCxiyyR.js → retry-DQt1zDgH.js} +238 -184
  40. package/dist/{runtime-DB7Fb6AU.js → runtime-BHsdEdr5.js} +1 -1
  41. package/dist/{runtime-BbWNtfyq.js → runtime-Ghtfh5ZA.js} +32 -24
  42. package/dist/runtime-api.js +18 -18
  43. package/dist/{send-BnTEIeLE.js → send-CZeWKUiA.js} +3 -3
  44. package/dist/{send.components-BWVEcjRP.js → send.components-BG5IDABj.js} +4 -4
  45. package/dist/{send.outbound-b5xwHTlL.js → send.outbound-DHuFUL6W.js} +10 -44
  46. package/dist/{send.shared-5vxBTagO.js → send.shared-B0n_fWzu.js} +62 -141
  47. package/dist/{sender-identity-Dt0imZyY.js → sender-identity-B8Ncfi63.js} +1 -1
  48. package/dist/setup-plugin-api.js +1 -1
  49. package/dist/{shared-BSJWHEMA.js → shared-C3dKun_X.js} +6 -3
  50. package/dist/{subagent-hooks-IzQKX4RQ.js → subagent-hooks-ZoQXEI1E.js} +2 -2
  51. package/dist/subagent-hooks-api.js +1 -1
  52. package/dist/{system-events-BOiDS8b3.js → system-events-DFF_awl7.js} +2 -2
  53. package/dist/{target-resolver-BVI8xSSC.js → target-resolver-DLtHDwMK.js} +2 -2
  54. package/dist/targets-BObRyaKX.js +3 -0
  55. package/dist/{thread-bindings-D7mcaXQC.js → thread-bindings-DKvsNjQR.js} +5 -5
  56. package/dist/{thread-bindings.manager-B1oySiVc.js → thread-bindings.manager-pbbRT5gb.js} +5 -5
  57. package/dist/{thread-bindings.session-updates-CC7HoQvt.js → thread-bindings.session-updates-t6jeFB1-.js} +1 -1
  58. package/dist/{thread-bindings.state-D8OMdk4O.js → thread-bindings.state-DtgOqsbX.js} +34 -1
  59. package/dist/{threading-OQnL-0Zx.js → threading-BCx49X-6.js} +3 -3
  60. package/dist/{transcripts-source-D8lH7AkN.js → transcripts-source-Dc6Nnli5.js} +48 -5
  61. package/dist/transcripts-source-api.js +1 -1
  62. package/dist/{typing-C9q0AQMb.js → typing-CuopV-Hi.js} +1 -1
  63. package/dist/{voice-runtime-Dfhjh1tV.js → voice-runtime-BQnILUbG.js} +82 -35
  64. package/node_modules/@discordjs/voice/node_modules/@types/node/README.md +1 -1
  65. package/node_modules/@discordjs/voice/node_modules/@types/node/buffer.buffer.d.ts +5 -5
  66. package/node_modules/@discordjs/voice/node_modules/@types/node/crypto.d.ts +2 -2
  67. package/node_modules/@discordjs/voice/node_modules/@types/node/http.d.ts +16 -0
  68. package/node_modules/@discordjs/voice/node_modules/@types/node/http2.d.ts +10 -2
  69. package/node_modules/@discordjs/voice/node_modules/@types/node/package.json +2 -2
  70. package/node_modules/@discordjs/voice/node_modules/@types/node/process.d.ts +52 -0
  71. package/node_modules/@discordjs/voice/node_modules/@types/node/quic.d.ts +210 -28
  72. package/node_modules/@discordjs/voice/node_modules/@types/node/sqlite.d.ts +6 -2
  73. package/node_modules/@discordjs/voice/node_modules/@types/node/test.d.ts +36 -0
  74. package/node_modules/typebox/build/format/json_pointer.d.mts +2 -2
  75. package/node_modules/typebox/build/format/json_pointer.mjs +2 -2
  76. package/node_modules/typebox/build/format/json_pointer_uri_fragment.d.mts +2 -2
  77. package/node_modules/typebox/build/format/json_pointer_uri_fragment.mjs +2 -2
  78. package/node_modules/typebox/build/format/relative_json_pointer.d.mts +2 -2
  79. package/node_modules/typebox/build/format/relative_json_pointer.mjs +2 -2
  80. package/node_modules/typebox/build/system/memory/assign.mjs +2 -1
  81. package/node_modules/typebox/build/system/memory/create.mjs +3 -4
  82. package/node_modules/typebox/build/system/memory/discard.mjs +2 -2
  83. package/node_modules/typebox/build/system/memory/freeze.d.mts +4 -0
  84. package/node_modules/typebox/build/system/memory/freeze.mjs +6 -0
  85. package/node_modules/typebox/build/system/memory/update.mjs +3 -2
  86. package/node_modules/typebox/package.json +1 -1
  87. package/node_modules/undici/docs/docs/api/Client.md +4 -0
  88. package/node_modules/undici/docs/docs/api/EnvHttpProxyAgent.md +3 -2
  89. package/node_modules/undici/docs/docs/api/EventSource.md +4 -0
  90. package/node_modules/undici/docs/docs/api/Interceptors.md +7 -3
  91. package/node_modules/undici/docs/docs/api/Socks5ProxyAgent.md +7 -1
  92. package/node_modules/undici/lib/cache/memory-cache-store.js +2 -2
  93. package/node_modules/undici/lib/core/symbols.js +2 -0
  94. package/node_modules/undici/lib/dispatcher/agent.js +7 -2
  95. package/node_modules/undici/lib/dispatcher/balanced-pool.js +7 -4
  96. package/node_modules/undici/lib/dispatcher/client-h1.js +12 -4
  97. package/node_modules/undici/lib/dispatcher/client-h2.js +43 -3
  98. package/node_modules/undici/lib/dispatcher/client.js +3 -2
  99. package/node_modules/undici/lib/dispatcher/dispatcher-base.js +13 -1
  100. package/node_modules/undici/lib/dispatcher/dispatcher.js +15 -1
  101. package/node_modules/undici/lib/dispatcher/dispatcher1-wrapper.js +3 -0
  102. package/node_modules/undici/lib/dispatcher/env-http-proxy-agent.js +10 -3
  103. package/node_modules/undici/lib/dispatcher/proxy-agent.js +18 -1
  104. package/node_modules/undici/lib/dispatcher/retry-agent.js +3 -0
  105. package/node_modules/undici/lib/dispatcher/round-robin-pool.js +1 -1
  106. package/node_modules/undici/lib/dispatcher/socks5-proxy-agent.js +91 -21
  107. package/node_modules/undici/lib/handler/cache-handler.js +29 -5
  108. package/node_modules/undici/lib/handler/decorator-handler.js +7 -1
  109. package/node_modules/undici/lib/handler/deduplication-handler.js +12 -6
  110. package/node_modules/undici/lib/handler/redirect-handler.js +21 -4
  111. package/node_modules/undici/lib/handler/retry-handler.js +148 -30
  112. package/node_modules/undici/lib/interceptor/cache.js +65 -12
  113. package/node_modules/undici/lib/interceptor/decompress.js +115 -13
  114. package/node_modules/undici/lib/interceptor/deduplicate.js +5 -4
  115. package/node_modules/undici/lib/interceptor/dns.js +7 -0
  116. package/node_modules/undici/lib/interceptor/dump.js +10 -23
  117. package/node_modules/undici/lib/mock/mock-agent.js +13 -1
  118. package/node_modules/undici/lib/mock/mock-utils.js +11 -10
  119. package/node_modules/undici/lib/util/cache.js +45 -3
  120. package/node_modules/undici/lib/web/cookies/parse.js +150 -147
  121. package/node_modules/undici/lib/web/eventsource/eventsource-stream.js +28 -1
  122. package/node_modules/undici/lib/web/eventsource/eventsource.js +13 -3
  123. package/node_modules/undici/lib/web/fetch/index.js +6 -0
  124. package/node_modules/undici/lib/web/fetch/request.js +2 -3
  125. package/node_modules/undici/lib/web/fetch/util.js +6 -1
  126. package/node_modules/undici/lib/web/webidl/index.js +3 -3
  127. package/node_modules/undici/lib/web/websocket/connection.js +1 -1
  128. package/node_modules/undici/lib/web/websocket/permessage-deflate.js +5 -0
  129. package/node_modules/undici/lib/web/websocket/stream/websocketstream.js +8 -10
  130. package/node_modules/undici/package.json +3 -3
  131. package/node_modules/undici/types/client.d.ts +10 -0
  132. package/node_modules/undici/types/interceptors.d.ts +2 -0
  133. package/node_modules/undici/types/mock-agent.d.ts +11 -10
  134. package/node_modules/undici/types/socks5-proxy-agent.d.ts +2 -0
  135. package/openclaw.plugin.json +1 -2
  136. package/package.json +6 -6
  137. package/dist/provider.runtime-Db8YwZBv.js +0 -2
  138. package/dist/targets-DbxVbq8k.js +0 -3
@@ -1,25 +1,107 @@
1
1
  import { n as discordRealtimeVoiceSecretOwnerId } from "./secret-config-contract-DFu9hfU8.js";
2
- import { a as convertRealtimePcm24kMonoToDiscordPcm48kStereo, c as maybeControlDiscordVoiceAgentRun, i as convertDiscordPcm48kStereoToRealtimePcm24kMono, l as formatVoiceLogPreview, n as loadDiscordVoiceSdk, o as createDiscordOpusEncodeStream, r as logVoiceVerbose, s as formatVoiceIngressPrompt } from "./voice-runtime-Dfhjh1tV.js";
2
+ import { a as createDiscordOpusEncodeStream, c as formatVoiceLogPreview, i as convertRealtimePcm24kMonoToDiscordPcm48kStereo, l as loadDiscordVoiceSdk, n as logVoiceVerbose, o as formatVoiceIngressPrompt, r as convertDiscordPcm48kStereoToRealtimePcm24kMono, s as maybeControlDiscordVoiceAgentRun } from "./voice-runtime-BQnILUbG.js";
3
3
  import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
4
4
  import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
5
5
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
6
6
  import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
7
7
  import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
8
8
  import { assertSecretOwnerAvailable, isSecretOwnerAvailable } from "openclaw/plugin-sdk/channel-secret-owner-runtime";
9
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONTROL_TOOL, REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, buildRealtimeVoiceAgentErrorProviderResult, buildRealtimeVoiceSessionInstructions, buildRealtimeVoiceSpeakExactMessage, canonicalizeRealtimeVoiceProviderId, classifyRealtimeVoiceConsultToolCall, classifySkippableRealtimeVoiceConsultTranscript, controlRealtimeVoiceAgentRun, createRealtimeVoiceAgentTalkbackQueue, createRealtimeVoiceSessionHarness, createRealtimeVoiceTurnContextTracker, isRealtimeVoiceWakeNameRequired, matchRealtimeVoiceActivationName, matchRealtimeVoiceConsultQuestions, parseRealtimeVoiceAgentControlToolArgs, realtimeVoiceAudioDurationMs, resolveConfiguredRealtimeVoiceProvider, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceBargeIn, resolveRealtimeVoiceInterruptResponseOnInputAudio, resolveRealtimeVoiceMinBargeInAudioEndMs, resolveRealtimeVoiceSessionPolicy } from "openclaw/plugin-sdk/realtime-voice";
9
+ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONTROL_TOOL, REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, buildRealtimeVoiceAgentErrorProviderResult, buildRealtimeVoiceSessionInstructions, buildRealtimeVoiceSpeakExactMessage, canonicalizeRealtimeVoiceProviderId, classifyRealtimeVoiceConsultToolCall, classifySkippableRealtimeVoiceConsultTranscript, controlRealtimeVoiceAgentRun, createRealtimeVoiceAgentTalkbackQueue, createRealtimeVoiceOutputActivityTracker, createRealtimeVoiceSessionHarness, isRealtimeVoiceWakeNameRequired, matchRealtimeVoiceActivationName, matchRealtimeVoiceConsultQuestions, parseRealtimeVoiceAgentControlToolArgs, realtimeVoiceAudioDurationMs, resolveConfiguredRealtimeVoiceProvider, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceBargeIn, resolveRealtimeVoiceInterruptResponseOnInputAudio, resolveRealtimeVoiceMinBargeInAudioEndMs, resolveRealtimeVoiceSessionPolicy } from "openclaw/plugin-sdk/realtime-voice";
10
10
  import { PassThrough, pipeline } from "node:stream";
11
+ //#region extensions/discord/src/voice/realtime-player.ts
12
+ /** One physical player serves every speaker lane in the room. */
13
+ var DiscordRealtimePlayer = class {
14
+ constructor(player) {
15
+ this.player = player;
16
+ this.queue = [];
17
+ this.lanes = /* @__PURE__ */ new Set();
18
+ this.changing = false;
19
+ this.closed = false;
20
+ this.onIdle = () => {
21
+ const request = this.current;
22
+ this.current = void 0;
23
+ if (request) this.transition(() => request.onIdle());
24
+ };
25
+ player.on(loadDiscordVoiceSdk().AudioPlayerStatus.Idle, this.onIdle);
26
+ }
27
+ registerLane(lane) {
28
+ this.lanes.add(lane);
29
+ return () => this.lanes.delete(lane);
30
+ }
31
+ enqueue(request) {
32
+ if (this.closed || this.current === request || this.queue.includes(request)) return;
33
+ this.queue.push(request);
34
+ this.drain();
35
+ }
36
+ cancel(request) {
37
+ this.queue = this.queue.filter((queued) => queued !== request);
38
+ if (this.current !== request) return;
39
+ this.current = void 0;
40
+ this.transition(() => this.player.stop(true));
41
+ }
42
+ handleBargeIn(reason = "barge-in") {
43
+ if (this.current) {
44
+ this.current.onBargeIn(reason);
45
+ return;
46
+ }
47
+ for (const lane of this.lanes) if (lane.hasOutput()) lane.onBargeIn(reason);
48
+ }
49
+ isActive() {
50
+ return this.current !== void 0 || Array.from(this.lanes).some((lane) => lane.hasOutput());
51
+ }
52
+ cancelForControl() {
53
+ this.transition(() => {
54
+ this.queue = [];
55
+ for (const lane of this.lanes) lane.cancelForControl();
56
+ });
57
+ }
58
+ close() {
59
+ this.closed = true;
60
+ this.queue = [];
61
+ this.lanes.clear();
62
+ this.current = void 0;
63
+ this.player.off(loadDiscordVoiceSdk().AudioPlayerStatus.Idle, this.onIdle);
64
+ this.player.stop(true);
65
+ }
66
+ transition(action) {
67
+ const wasChanging = this.changing;
68
+ this.changing = true;
69
+ try {
70
+ action();
71
+ } finally {
72
+ this.changing = wasChanging;
73
+ this.drain();
74
+ }
75
+ }
76
+ drain() {
77
+ if (this.closed || this.changing || this.current) return;
78
+ const next = this.queue.shift();
79
+ if (!next) return;
80
+ this.current = next;
81
+ this.transition(() => {
82
+ try {
83
+ this.player.play(next.createResource());
84
+ next.onStart();
85
+ } catch (error) {
86
+ this.current = void 0;
87
+ this.player.stop(true);
88
+ next.onError(error);
89
+ }
90
+ });
91
+ }
92
+ };
93
+ //#endregion
11
94
  //#region extensions/discord/src/voice/realtime-transcript.ts
95
+ const PARTIAL_TRANSCRIPT_MAX_CHARS = 240;
12
96
  function mergeRealtimePartialTranscript(previous, next) {
13
97
  const trimmed = next.trim();
14
98
  if (!trimmed) return previous;
15
99
  const merged = trimmed.startsWith(previous) ? trimmed : `${previous}${next}`;
16
- return sliceUtf16Safe(merged, -240);
100
+ return sliceUtf16Safe(merged, 0, PARTIAL_TRANSCRIPT_MAX_CHARS);
17
101
  }
18
102
  //#endregion
19
103
  //#region extensions/discord/src/voice/realtime-turns.ts
20
- const logger$3 = createSubsystemLogger("discord/voice");
21
- const DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT = 32;
22
- const DISCORD_REALTIME_IGNORED_WAKE_NAME_CONTEXT_TTL_MS = 1e4;
104
+ const logger$5 = createSubsystemLogger("discord/voice");
23
105
  const DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS = 1e4;
24
106
  const REALTIME_PCM16_BYTES_PER_SAMPLE = 2;
25
107
  const DISCORD_REALTIME_TRAILING_SILENCE_MIN_MS = 700;
@@ -27,49 +109,59 @@ const DISCORD_REALTIME_TRAILING_SILENCE_MAX_MS = 3e3;
27
109
  var DiscordRealtimeTurns = class {
28
110
  constructor(params) {
29
111
  this.params = params;
30
- this.speakerTurns = createRealtimeVoiceTurnContextTracker({
31
- limit: DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT,
32
- ignoredContextTtlMs: DISCORD_REALTIME_IGNORED_WAKE_NAME_CONTEXT_TTL_MS,
33
- deferUntilAudio: true
34
- });
112
+ this.pendingAudio = false;
35
113
  this.partialUserTranscript = "";
36
114
  this.wakeNameAckedForTurn = false;
37
115
  }
38
116
  beginSpeakerTurn(context, userId) {
117
+ if (this.source && (this.source.userId !== userId || this.source.senderIsOwner !== context.senderIsOwner)) throw new Error("A Discord realtime connection cannot change speaker authority");
118
+ this.source ??= Object.freeze({
119
+ userId,
120
+ senderIsOwner: context.senderIsOwner
121
+ });
39
122
  this.resetPartialWakeNameTracking();
40
- const turn = this.speakerTurns.open({
41
- ...context,
42
- userId
43
- }, {
123
+ const turn = {
124
+ context: {
125
+ ...context,
126
+ ...this.source
127
+ },
128
+ startedAt: Date.now(),
129
+ hasAudio: false,
130
+ closed: false,
44
131
  transcripts: this.params.entry.transcripts,
45
132
  inputDiscordBytes: 0,
46
133
  inputRealtimeBytes: 0,
47
134
  inputChunks: 0,
48
135
  interruptedPlayback: false
49
- });
136
+ };
50
137
  return {
51
138
  sendInputAudio: (discordPcm48kStereo) => this.sendInputAudioForTurn(turn, discordPcm48kStereo),
52
139
  close: () => {
53
140
  this.sendRealtimeTrailingSilenceForTurn(turn);
54
141
  this.logSpeakerTurnClosed(turn);
55
- this.speakerTurns.close(turn);
142
+ turn.closed = true;
56
143
  }
57
144
  };
58
145
  }
59
146
  handlePartialUserTranscript(text) {
60
- if (!this.isWakeNameRequired() || this.wakeNameAckedForTurn) return;
147
+ if (!this.lastSpeakerTurn || !this.isWakeNameRequired() || this.wakeNameAckedForTurn) return;
61
148
  this.partialUserTranscript = mergeRealtimePartialTranscript(this.partialUserTranscript, text);
62
149
  const wakeNameResult = matchRealtimeVoiceActivationName(this.partialUserTranscript, this.params.wakeNames());
63
- if (!wakeNameResult || wakeNameResult.edge !== "leading") return;
150
+ if (wakeNameResult?.edge !== "leading" || wakeNameResult.match !== "exact") return;
64
151
  this.wakeNameAckedForTurn = true;
65
152
  this.params.playback.sendWakeNameAck(wakeNameResult);
66
153
  }
67
154
  async handleFinalUserTranscript(text) {
155
+ if (!this.lastSpeakerTurn || this.params.stopped()) return;
68
156
  const providerEpoch = this.params.providerEpoch();
69
157
  const trimmed = text.trim();
70
- if (!trimmed) return;
158
+ if (!trimmed) {
159
+ this.pendingAudio = false;
160
+ this.resetPartialWakeNameTracking();
161
+ return;
162
+ }
71
163
  this.partialUserTranscript = "";
72
- const transcriptsTurn = this.peekPendingSpeakerTurn();
164
+ const transcriptsTurn = this.lastSpeakerTurn;
73
165
  let transcriptAttribution = this.transcriptAttributionFromTurn(transcriptsTurn);
74
166
  const humanParticipantCount = this.params.getHumanParticipantCount();
75
167
  const requireWakeName = this.isWakeNameRequired(humanParticipantCount);
@@ -80,12 +172,12 @@ var DiscordRealtimeTurns = class {
80
172
  transcriptAttribution ??= pendingWakeNameFollowup;
81
173
  if (!pendingWakeNameFollowup) {
82
174
  this.recordTranscriptUtterance(trimmed, transcriptAttribution, providerEpoch);
83
- this.rememberIgnoredWakeNameSpeakerContext(this.consumePendingSpeakerContext());
84
- logger$3.info(`discord voice: realtime wake-name gate ignored transcript chars=${trimmed.length} humanParticipants=${humanParticipantCount} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId} wakeNames=${this.params.wakeNames().join(",") || "none"}`);
175
+ this.consumePendingSpeakerContext();
176
+ logger$5.info(`discord voice: realtime wake-name gate ignored transcript chars=${trimmed.length} humanParticipants=${humanParticipantCount} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId} wakeNames=${this.params.wakeNames().join(",") || "none"}`);
85
177
  return;
86
178
  }
87
179
  forcedSpeakerContext = pendingWakeNameFollowup.context;
88
- logger$3.info(`discord voice: realtime wake-name follow-up accepted chars=${trimmed.length} speaker=${forcedSpeakerContext.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
180
+ logger$5.info(`discord voice: realtime wake-name follow-up accepted chars=${trimmed.length} speaker=${forcedSpeakerContext.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
89
181
  }
90
182
  this.recordTranscriptUtterance(trimmed, transcriptAttribution, providerEpoch);
91
183
  const acceptedText = wakeNameResult.allowed ? wakeNameResult.text || trimmed : trimmed;
@@ -103,23 +195,24 @@ var DiscordRealtimeTurns = class {
103
195
  resetProviderContinuity() {
104
196
  this.partialUserTranscript = "";
105
197
  this.pendingWakeNameFollowup = void 0;
198
+ this.pendingAudio = false;
199
+ this.lastSpeakerTurn = void 0;
106
200
  }
107
201
  clear() {
108
- this.speakerTurns.clear();
202
+ this.pendingAudio = false;
203
+ this.lastSpeakerTurn = void 0;
109
204
  this.resetPartialWakeNameTracking();
110
205
  this.pendingWakeNameFollowup = void 0;
111
206
  }
112
207
  consumePendingSpeakerContext() {
113
- return this.speakerTurns.consumeAudioContext();
208
+ this.pendingAudio = false;
209
+ return this.speakerContext();
114
210
  }
115
- consumeRecentIgnoredWakeNameSpeakerContext() {
116
- return this.speakerTurns.consumeIgnoredContext();
117
- }
118
- peekPendingSpeakerTurn() {
119
- return this.speakerTurns.peekAudioTurn();
211
+ speakerContext() {
212
+ return this.lastSpeakerTurn?.context;
120
213
  }
121
214
  hasPendingSpeakerAudioContext() {
122
- return this.speakerTurns.hasAudioContext();
215
+ return this.pendingAudio;
123
216
  }
124
217
  sendInputAudioForTurn(turn, discordPcm48kStereo) {
125
218
  const bridge = this.params.bridge();
@@ -130,27 +223,28 @@ var DiscordRealtimeTurns = class {
130
223
  turn.inputDiscordBytes += discordPcm48kStereo.length;
131
224
  turn.inputRealtimeBytes += realtimePcm.length;
132
225
  turn.inputChunks += 1;
133
- if (turn.inputChunks === 1) logger$3.info(`discord voice: realtime input audio started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length} outputAudioMs=${this.params.playback.outputAudioMs()} outputActive=${this.params.playback.isOutputAudioActive()}`);
134
- const outputActive = this.params.playback.hasInterruptibleOutputAudio();
135
- if (!turn.interruptedPlayback && this.params.playback.isBargeInEnabled() && outputActive) {
226
+ if (turn.inputChunks === 1) logger$5.info(`discord voice: realtime input audio started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length} outputAudioMs=${this.params.playback.outputAudioMs()} outputActive=${this.params.playback.isOutputAudioActive()}`);
227
+ if (!turn.interruptedPlayback && this.params.interruptRoomPlayback()) {
136
228
  turn.interruptedPlayback = true;
137
229
  logVoiceVerbose(`realtime barge-in from active speaker audio: guild ${this.params.entry.guildId} channel ${this.params.entry.channelId} user ${turn.context.userId}`);
138
- logger$3.info(`discord voice: realtime barge-in detected source=active-speaker-audio guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} outputAudioMs=${this.params.playback.outputAudioMs()} outputActive=${this.params.playback.isOutputAudioActive()} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length}`);
139
- this.params.playback.handleBargeIn("active-speaker-audio");
230
+ logger$5.info(`discord voice: realtime barge-in detected source=active-speaker-audio guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length}`);
140
231
  }
141
232
  if (this.params.recordInputAudio(realtimePcm)) bridge.sendAudio(realtimePcm);
142
233
  }
143
234
  }
144
235
  registerSpeakerTurnAudioStarted(turn) {
236
+ this.pendingAudio = true;
237
+ this.lastSpeakerTurn = turn;
238
+ turn.lastAudioAt = Date.now();
145
239
  if (turn.hasAudio) return;
146
- this.speakerTurns.markAudio(turn);
147
- logger$3.info(`discord voice: realtime speaker turn opened guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} owner=${turn.context.senderIsOwner} pendingTurns=${this.speakerTurns.size()}`);
240
+ turn.hasAudio = true;
241
+ logger$5.info(`discord voice: realtime speaker turn opened guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} owner=${turn.context.senderIsOwner}`);
148
242
  }
149
243
  logSpeakerTurnClosed(turn) {
150
244
  if (turn.closed || !turn.hasAudio) return;
151
245
  const elapsedMs = Date.now() - turn.startedAt;
152
246
  const sinceLastAudioMs = turn.lastAudioAt ? Date.now() - turn.lastAudioAt : void 0;
153
- logger$3.info(`discord voice: realtime speaker turn closed guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} owner=${turn.context.senderIsOwner} hasAudio=${turn.hasAudio} chunks=${turn.inputChunks} discordBytes=${turn.inputDiscordBytes} realtimeBytes=${turn.inputRealtimeBytes} elapsedMs=${elapsedMs}${sinceLastAudioMs === void 0 ? "" : ` sinceLastAudioMs=${sinceLastAudioMs}`} interruptedPlayback=${turn.interruptedPlayback}`);
247
+ logger$5.info(`discord voice: realtime speaker turn closed guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} owner=${turn.context.senderIsOwner} hasAudio=${turn.hasAudio} chunks=${turn.inputChunks} discordBytes=${turn.inputDiscordBytes} realtimeBytes=${turn.inputRealtimeBytes} elapsedMs=${elapsedMs}${sinceLastAudioMs === void 0 ? "" : ` sinceLastAudioMs=${sinceLastAudioMs}`} interruptedPlayback=${turn.interruptedPlayback}`);
154
248
  }
155
249
  sendRealtimeTrailingSilenceForTurn(turn) {
156
250
  const bridge = this.params.bridge();
@@ -161,7 +255,7 @@ var DiscordRealtimeTurns = class {
161
255
  const silenceBytes = Math.ceil(24e3 * silenceMs / 1e3) * REALTIME_PCM16_BYTES_PER_SAMPLE;
162
256
  const silence = Buffer.alloc(silenceBytes);
163
257
  bridge.sendAudio(silence);
164
- logger$3.info(`discord voice: realtime trailing silence sent guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} silenceMs=${silenceMs} realtimeBytes=${silence.length}`);
258
+ logger$5.info(`discord voice: realtime trailing silence sent guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} silenceMs=${silenceMs} realtimeBytes=${silence.length}`);
165
259
  }
166
260
  resolveWakeNameTranscript(text, requireWakeName) {
167
261
  if (!requireWakeName) return {
@@ -174,7 +268,7 @@ var DiscordRealtimeTurns = class {
174
268
  };
175
269
  const wakeNameResult = matchRealtimeVoiceActivationName(text, this.params.wakeNames());
176
270
  if (wakeNameResult) {
177
- logger$3.info(`discord voice: realtime wake-name gate matched canonical=${wakeNameResult.activationName} heard=${wakeNameResult.heardName} match=${wakeNameResult.match} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
271
+ logger$5.info(`discord voice: realtime wake-name gate matched canonical=${wakeNameResult.activationName} heard=${wakeNameResult.heardName} match=${wakeNameResult.match} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
178
272
  return wakeNameResult;
179
273
  }
180
274
  return {
@@ -216,14 +310,14 @@ var DiscordRealtimeTurns = class {
216
310
  if (providerEpoch !== this.params.providerEpoch() || this.params.entry.transcripts !== transcripts) return;
217
311
  return transcripts.onUtterance(utterance);
218
312
  }).catch((error) => {
219
- logger$3.warn(`discord voice: realtime transcripts utterance failed: ${formatErrorMessage(error)}`);
313
+ logger$5.warn(`discord voice: realtime transcripts utterance failed: ${formatErrorMessage(error)}`);
220
314
  });
221
315
  }
222
316
  armWakeNameFollowup() {
223
- const turn = this.peekPendingSpeakerTurn();
317
+ const turn = this.lastSpeakerTurn;
224
318
  const context = this.consumePendingSpeakerContext();
225
319
  if (!context) {
226
- logger$3.warn(`discord voice: realtime wake-name follow-up has no speaker context voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
320
+ logger$5.warn(`discord voice: realtime wake-name follow-up has no speaker context voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
227
321
  return;
228
322
  }
229
323
  const expiresAt = resolveExpiresAtMsFromDurationMs(DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS);
@@ -234,7 +328,7 @@ var DiscordRealtimeTurns = class {
234
328
  startedAt: turn?.startedAt ?? Date.now(),
235
329
  expiresAt
236
330
  };
237
- logger$3.info(`discord voice: realtime wake-name follow-up armed speaker=${context.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
331
+ logger$5.info(`discord voice: realtime wake-name follow-up armed speaker=${context.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
238
332
  }
239
333
  consumePendingWakeNameFollowup() {
240
334
  const pending = this.pendingWakeNameFollowup;
@@ -242,25 +336,20 @@ var DiscordRealtimeTurns = class {
242
336
  const now = asDateTimestampMs(Date.now());
243
337
  const expiresAt = pending ? asDateTimestampMs(pending.expiresAt) : void 0;
244
338
  if (!pending || now === void 0 || expiresAt === void 0 || now > expiresAt) return;
245
- const currentTurn = this.peekPendingSpeakerTurn();
246
- if (currentTurn && currentTurn.context.userId !== pending.context.userId) return;
247
- if (currentTurn) this.consumePendingSpeakerContext();
339
+ this.consumePendingSpeakerContext();
248
340
  return {
249
341
  context: pending.context,
250
342
  startedAt: pending.startedAt,
251
343
  transcripts: pending.transcripts
252
344
  };
253
345
  }
254
- rememberIgnoredWakeNameSpeakerContext(context) {
255
- this.speakerTurns.rememberIgnoredContext(context);
256
- }
257
346
  };
258
347
  function isDiscordRealtimeSpeakerContext(value) {
259
348
  return Boolean(value) && typeof value === "object" && typeof value.userId === "string" && typeof value.senderIsOwner === "boolean" && typeof value.speakerLabel === "string";
260
349
  }
261
350
  //#endregion
262
351
  //#region extensions/discord/src/voice/realtime-consults.ts
263
- const logger$2 = createSubsystemLogger("discord/voice");
352
+ const logger$4 = createSubsystemLogger("discord/voice");
264
353
  const DISCORD_REALTIME_TALKBACK_DEBOUNCE_MS = 350;
265
354
  const DISCORD_REALTIME_FALLBACK_TEXT = "I hit an error while checking that. Please try again.";
266
355
  const DISCORD_REALTIME_FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
@@ -279,6 +368,9 @@ var DiscordRealtimeConsults = class {
279
368
  this.talkback.close();
280
369
  this.clearProviderConsultState();
281
370
  }
371
+ isIdle() {
372
+ return this.talkback.isIdle() && this.providerDeliveries.size === 0 && this.params.harness.forcedConsults.handles().every((handle) => handle.context?.result);
373
+ }
282
374
  resetProviderContinuity() {
283
375
  this.talkback.close();
284
376
  this.talkback = this.createTalkbackQueue();
@@ -287,6 +379,10 @@ var DiscordRealtimeConsults = class {
287
379
  async handleToolCall(event, session) {
288
380
  const providerEpoch = this.params.providerEpoch();
289
381
  const callId = event.callId || event.itemId || "unknown";
382
+ if (this.params.stopped() || !this.params.turns.speakerContext()) {
383
+ await session.submitToolResult(callId, { error: "No Discord speaker context available" });
384
+ return;
385
+ }
290
386
  if (event.name === REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME) {
291
387
  await this.handleAgentControlToolCall(event, session, callId, providerEpoch);
292
388
  return;
@@ -302,16 +398,16 @@ var DiscordRealtimeConsults = class {
302
398
  const outcome = classifyRealtimeVoiceConsultToolCall(event.args, { retainedExactSpeechTexts: this.params.playback.retainedExactSpeechTexts() });
303
399
  switch (outcome.kind) {
304
400
  case "exact-speech-echo":
305
- logger$2.info(`discord voice: realtime exact speech consult bypassed call=${callId || "unknown"} answerChars=${outcome.text.length}`);
401
+ logger$4.info(`discord voice: realtime exact speech consult bypassed call=${callId || "unknown"} answerChars=${outcome.text.length}`);
306
402
  await session.submitToolResult(callId, { text: outcome.text });
307
403
  return;
308
404
  case "malformed":
309
- logger$2.warn(`discord voice: realtime consult rejected malformed args call=${callId || "unknown"}: ${outcome.error}`);
405
+ logger$4.warn(`discord voice: realtime consult rejected malformed args call=${callId || "unknown"}: ${outcome.error}`);
310
406
  await session.submitToolResult(callId, { error: outcome.error });
311
407
  return;
312
408
  }
313
409
  const consultMessage = outcome.message;
314
- logger$2.info(`discord voice: realtime consult requested call=${callId || "unknown"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} question=${formatVoiceLogPreview(consultMessage)}`);
410
+ logger$4.info(`discord voice: realtime consult requested call=${callId || "unknown"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} question=${formatVoiceLogPreview(consultMessage)}`);
315
411
  const nativeConsult = this.params.harness.forcedConsults.recordNativeConsult(event.args, callId);
316
412
  if (nativeConsult.kind === "already_delivered" && this.params.harness.forcedConsults.isCancelled(nativeConsult.handle)) {
317
413
  await this.submitTerminalRealtimeToolResult(callId, session, CANCELLED_CONSULT_RESULT);
@@ -326,7 +422,7 @@ var DiscordRealtimeConsults = class {
326
422
  if (recentConsult) {
327
423
  const recentSpeaker = recentConsult.context?.speaker;
328
424
  if (this.params.turns.hasPendingSpeakerAudioContext()) {
329
- logger$2.info(`discord voice: realtime consult matched recent agent result but newer speaker audio is pending call=${callId} speaker=${recentSpeaker?.speakerLabel ?? "unknown"} owner=${recentSpeaker?.senderIsOwner ?? false}`);
425
+ logger$4.info(`discord voice: realtime consult matched recent agent result but newer speaker audio is pending call=${callId} speaker=${recentSpeaker?.speakerLabel ?? "unknown"} owner=${recentSpeaker?.senderIsOwner ?? false}`);
330
426
  await session.submitToolResult(callId, { error: "Discord speaker context changed before this realtime consult completed" });
331
427
  return;
332
428
  }
@@ -341,7 +437,7 @@ var DiscordRealtimeConsults = class {
341
437
  });
342
438
  }
343
439
  if (!context) {
344
- logger$2.warn(`discord voice: realtime consult has no speaker context call=${callId || "unknown"}`);
440
+ logger$4.warn(`discord voice: realtime consult has no speaker context call=${callId || "unknown"}`);
345
441
  await session.submitToolResult(callId, { error: "No Discord speaker context available" });
346
442
  return;
347
443
  }
@@ -350,8 +446,8 @@ var DiscordRealtimeConsults = class {
350
446
  message: consultMessage
351
447
  }));
352
448
  if (providerEpoch !== this.params.providerEpoch()) return;
353
- if ("text" in result) logger$2.info(`discord voice: realtime consult answer (${result.text.length} chars) voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel} owner=${context.senderIsOwner}: ${formatVoiceLogPreview(result.text)}`);
354
- else if ("error" in result) logger$2.warn(`discord voice: realtime consult failed call=${callId}: ${result.error}`);
449
+ if ("text" in result) logger$4.info(`discord voice: realtime consult answer (${result.text.length} chars) voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel} owner=${context.senderIsOwner}: ${formatVoiceLogPreview(result.text)}`);
450
+ else if ("error" in result) logger$4.warn(`discord voice: realtime consult failed call=${callId}: ${result.error}`);
355
451
  await this.submitAgentProxyConsultResult(callId, session, result);
356
452
  }
357
453
  async handleAcceptedTranscript(acceptedText, forcedSpeakerContext, providerEpoch) {
@@ -366,7 +462,7 @@ var DiscordRealtimeConsults = class {
366
462
  });
367
463
  } catch (error) {
368
464
  if (providerEpoch !== this.params.providerEpoch()) return;
369
- logger$2.warn(`discord voice: realtime active-run control failed; falling back to normal transcript handling: ${formatErrorMessage(error)}`);
465
+ logger$4.warn(`discord voice: realtime active-run control failed; falling back to normal transcript handling: ${formatErrorMessage(error)}`);
370
466
  control = void 0;
371
467
  }
372
468
  if (providerEpoch !== this.params.providerEpoch()) return;
@@ -388,7 +484,7 @@ var DiscordRealtimeConsults = class {
388
484
  return createRealtimeVoiceAgentTalkbackQueue({
389
485
  debounceMs: this.params.debounceMs() ?? DISCORD_REALTIME_TALKBACK_DEBOUNCE_MS,
390
486
  isStopped: () => this.params.stopped() || providerEpoch !== this.params.providerEpoch(),
391
- logger: logger$2,
487
+ logger: logger$4,
392
488
  logPrefix: "[discord] realtime agent",
393
489
  responseStyle: "Brief, natural spoken answer for a Discord voice channel.",
394
490
  fallbackText: DISCORD_REALTIME_FALLBACK_TEXT,
@@ -431,7 +527,7 @@ var DiscordRealtimeConsults = class {
431
527
  });
432
528
  }
433
529
  logAgentControlResult(result) {
434
- logger$2.info(`discord voice: realtime active-run control handled mode=${result.mode} ok=${result.ok} active=${result.active} reason=${result.reason ?? "none"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId}`);
530
+ logger$4.info(`discord voice: realtime active-run control handled mode=${result.mode} ok=${result.ok} active=${result.active} reason=${result.reason ?? "none"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId}`);
435
531
  }
436
532
  prepareForcedAgentProxyConsult(transcript, speakerContext) {
437
533
  if (this.params.consultPolicy() !== "always" && this.params.wakeNamePolicy() === "never") return;
@@ -440,18 +536,17 @@ var DiscordRealtimeConsults = class {
440
536
  const skipReason = classifySkippableRealtimeVoiceConsultTranscript(question);
441
537
  if (skipReason) {
442
538
  const context = this.params.turns.consumePendingSpeakerContext();
443
- logger$2.info(`discord voice: realtime forced agent consult skipped reason=${skipReason} chars=${question.length} speaker=${context?.speakerLabel ?? "unknown"} transcript=${formatVoiceLogPreview(question)}`);
539
+ logger$4.info(`discord voice: realtime forced agent consult skipped reason=${skipReason} chars=${question.length} speaker=${context?.speakerLabel ?? "unknown"} transcript=${formatVoiceLogPreview(question)}`);
444
540
  return;
445
541
  }
446
- let context = speakerContext ?? this.params.turns.consumePendingSpeakerContext();
447
- if (!context) context = this.params.turns.consumeRecentIgnoredWakeNameSpeakerContext();
542
+ const context = speakerContext ?? this.params.turns.consumePendingSpeakerContext();
448
543
  if (!context) {
449
544
  const recent = this.findRecentAgentProxyConsultContext(question);
450
545
  if (recent) {
451
546
  logVoiceVerbose(`realtime forced agent consult skipped (already delegated): guild ${this.params.entry.guildId} channel ${this.params.entry.channelId} speaker ${recent.context?.speaker.userId ?? "unknown"}`);
452
547
  return;
453
548
  }
454
- logger$2.warn("discord voice: realtime forced agent consult has no speaker context");
549
+ logger$4.warn("discord voice: realtime forced agent consult has no speaker context");
455
550
  return;
456
551
  }
457
552
  return this.params.harness.forcedConsults.prepare(question, { context: {
@@ -476,9 +571,9 @@ var DiscordRealtimeConsults = class {
476
571
  return;
477
572
  }
478
573
  const startedAt = Date.now();
479
- logger$2.info(`discord voice: realtime forced agent consult starting chars=${question.length} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel} owner=${context.senderIsOwner}`);
480
- logger$2.debug(`discord voice: realtime forced agent consult reason=${DISCORD_REALTIME_FORCED_CONSULT_REASON} consultPolicy=${this.params.consultPolicy()} wakeNamePolicy=${this.params.wakeNamePolicy()} requireWakeName=${this.params.isWakeNameRequired()} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel}`);
481
- if (this.params.playback.hasInterruptibleOutputAudio()) logger$2.info(`discord voice: realtime forced agent consult preserving active playback guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${this.params.playback.outputAudioMs()} outputActive=${this.params.playback.isOutputAudioActive()} playbackChunks=${this.params.harness.outputActivity.snapshot().chunks}`);
574
+ logger$4.info(`discord voice: realtime forced agent consult starting chars=${question.length} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel} owner=${context.senderIsOwner}`);
575
+ logger$4.debug(`discord voice: realtime forced agent consult reason=${DISCORD_REALTIME_FORCED_CONSULT_REASON} consultPolicy=${this.params.consultPolicy()} wakeNamePolicy=${this.params.wakeNamePolicy()} requireWakeName=${this.params.isWakeNameRequired()} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel}`);
576
+ if (this.params.playback.hasInterruptibleOutputAudio()) logger$4.info(`discord voice: realtime forced agent consult preserving active playback guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${this.params.playback.outputAudioMs()} outputActive=${this.params.playback.isOutputAudioActive()} playbackChunks=${this.params.harness.outputActivity.snapshot().chunks}`);
482
577
  state.handledByForcedPlayback = true;
483
578
  const result = await this.trackAgentProxyConsult(pending, this.runAgentTurn({
484
579
  context,
@@ -487,8 +582,8 @@ var DiscordRealtimeConsults = class {
487
582
  const deliveries = this.providerDeliveries.get(state);
488
583
  await Promise.all(Array.from(deliveries ?? [], (delivery) => delivery.promise));
489
584
  if (state.providerEpoch !== this.params.providerEpoch() || "status" in result) return;
490
- if ("text" in result) logger$2.info(`discord voice: realtime forced agent consult answer (${result.text.length} chars) elapsedMs=${Date.now() - startedAt} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId}: ${formatVoiceLogPreview(result.text)}`);
491
- else logger$2.warn(`discord voice: realtime forced agent consult failed elapsedMs=${Date.now() - startedAt}: ${result.error}`);
585
+ if ("text" in result) logger$4.info(`discord voice: realtime forced agent consult answer (${result.text.length} chars) elapsedMs=${Date.now() - startedAt} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId}: ${formatVoiceLogPreview(result.text)}`);
586
+ else logger$4.warn(`discord voice: realtime forced agent consult failed elapsedMs=${Date.now() - startedAt}: ${result.error}`);
492
587
  const text = "text" in result ? result.text : DISCORD_REALTIME_FALLBACK_TEXT;
493
588
  if (text.trim() && state.handledByForcedPlayback) this.params.playback.enqueueExactSpeechMessage(text);
494
589
  }
@@ -540,7 +635,7 @@ var DiscordRealtimeConsults = class {
540
635
  deliveries.add(providerDelivery);
541
636
  this.providerDeliveries.set(state, deliveries);
542
637
  }
543
- logger$2.info(`discord voice: realtime consult ${state.result ? "reused recent" : "joined in-flight"} agent result call=${callId} speaker=${state.speaker.speakerLabel} owner=${state.speaker.senderIsOwner}`);
638
+ logger$4.info(`discord voice: realtime consult ${state.result ? "reused recent" : "joined in-flight"} agent result call=${callId} speaker=${state.speaker.speakerLabel} owner=${state.speaker.senderIsOwner}`);
544
639
  try {
545
640
  const result = await pendingResult;
546
641
  if (state.providerEpoch !== this.params.providerEpoch()) return true;
@@ -574,11 +669,223 @@ var DiscordRealtimeConsults = class {
574
669
  }
575
670
  };
576
671
  //#endregion
672
+ //#region extensions/discord/src/voice/realtime-output.ts
673
+ const logger$3 = createSubsystemLogger("discord/voice");
674
+ const DISCORD_RAW_PCM_FRAME_BYTES = 3840;
675
+ const DISCORD_RAW_PCM_BYTES_PER_MS = 192;
676
+ const DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES = 25;
677
+ const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 3e3;
678
+ /** One output stream retains ownership through queued and audible playback. */
679
+ var DiscordRealtimeOutput = class {
680
+ constructor(params) {
681
+ this.params = params;
682
+ this.activity = createRealtimeVoiceOutputActivityTracker();
683
+ this.stream = new PassThrough({ highWaterMark: DISCORD_RAW_PCM_FRAME_BYTES * 128 });
684
+ this.playbackMarks = [];
685
+ this.playedPcmBytes = 0;
686
+ this.reportedPlaybackMs = 0;
687
+ this.audioSpans = [];
688
+ this.buffers = [];
689
+ this.bufferedBytes = 0;
690
+ this.closed = false;
691
+ this.failed = false;
692
+ this.stream.once("close", () => {
693
+ if (!this.activity.snapshot().playbackStarted) this.close("stream-close");
694
+ });
695
+ }
696
+ pendingBytes() {
697
+ return this.closed ? 0 : this.bufferedBytes + this.stream.writableLength + this.stream.readableLength;
698
+ }
699
+ playbackItems() {
700
+ const playedMs = Math.min(this.playedPcmBytes / DISCORD_RAW_PCM_BYTES_PER_MS, this.activity.snapshot().audioMs);
701
+ for (const span of this.audioSpans) span.item.audioEndMs += Math.max(0, Math.min(playedMs, span.endMs) - Math.max(this.reportedPlaybackMs, span.startMs));
702
+ this.reportedPlaybackMs = playedMs;
703
+ return this.audioSpans.filter(({ endMs }) => endMs > playedMs).map(({ item }) => item);
704
+ }
705
+ markPlayback(acknowledge) {
706
+ if (!this.closed) this.playbackMarks.push({
707
+ endBytes: this.activity.snapshot().sinkAudioBytes,
708
+ acknowledge
709
+ });
710
+ }
711
+ append(pcm, activity, item) {
712
+ if (this.closed) return;
713
+ const startMs = this.activity.snapshot().audioMs;
714
+ if (item) {
715
+ const previous = this.audioSpans.at(-1);
716
+ const endMs = startMs + activity.audioMs;
717
+ if (previous?.item === item && previous.endMs === startMs) previous.endMs = endMs;
718
+ else this.audioSpans.push({
719
+ item,
720
+ startMs,
721
+ endMs
722
+ });
723
+ }
724
+ this.activity.markAudio(activity);
725
+ if (this.activity.snapshot().playbackStarted && !this.drainHandler) {
726
+ if (!this.stream.write(pcm)) this.waitForDrain();
727
+ return;
728
+ }
729
+ this.buffers.push(pcm);
730
+ this.bufferedBytes += pcm.length;
731
+ if (!this.drainHandler && this.bufferedBytes >= DISCORD_RAW_PCM_FRAME_BYTES * DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES) this.startPlayback();
732
+ }
733
+ finish(reason, playBuffered) {
734
+ if (this.closed) return;
735
+ this.activity.markStreamEnding();
736
+ const activity = this.activity.snapshot();
737
+ logger$3.info(`discord voice: realtime audio playback finishing reason=${reason} ${this.params.logContext} audioMs=${Math.floor(activity.audioMs)} chunks=${activity.chunks}`);
738
+ if (!playBuffered) {
739
+ this.close(reason);
740
+ return;
741
+ }
742
+ this.startPlayback();
743
+ if (this.activity.snapshot().playbackStarted) {
744
+ this.scheduleWatchdog(reason);
745
+ if (!this.drainHandler) this.stream.end();
746
+ }
747
+ }
748
+ close(reason) {
749
+ if (this.closed) return;
750
+ const playbackRetirement = reason === "player-idle" || reason === "output-pipeline-error" || reason === "playback-watchdog";
751
+ const heardMarks = playbackRetirement ? this.playbackMarks.filter((mark) => mark.endBytes <= this.playedPcmBytes) : [];
752
+ const lostPlaybackMarks = playbackRetirement && this.playbackMarks.some((mark) => mark.endBytes > this.playedPcmBytes);
753
+ this.closed = true;
754
+ this.playbackMarks = [];
755
+ this.playbackItems();
756
+ this.clearWatchdog();
757
+ const activity = this.activity.snapshot();
758
+ logger$3.info(`discord voice: realtime audio playback stopped reason=${reason} ${this.params.logContext} audioMs=${Math.floor(activity.audioMs)} elapsedMs=${this.activity.elapsedPlaybackMs()} chunks=${activity.chunks} discordBytes=${activity.sinkAudioBytes} realtimeBytes=${activity.sourceAudioBytes}`);
759
+ this.buffers = [];
760
+ this.bufferedBytes = 0;
761
+ if (this.drainHandler) {
762
+ this.stream.off("drain", this.drainHandler);
763
+ this.drainHandler = void 0;
764
+ }
765
+ this.stream.end();
766
+ this.stream.destroy();
767
+ if (lostPlaybackMarks) this.params.onError(/* @__PURE__ */ new Error(`Discord realtime audio stopped before playback completed: ${reason}`));
768
+ this.params.onClose(this, reason);
769
+ if (this.request) {
770
+ this.params.player.cancel(this.request);
771
+ this.request = void 0;
772
+ }
773
+ try {
774
+ for (const mark of heardMarks) mark.acknowledge();
775
+ } catch (error) {
776
+ this.params.onError(error);
777
+ }
778
+ }
779
+ startPlayback() {
780
+ if (this.closed || this.request) return;
781
+ this.request = {
782
+ createResource: () => this.createResource(),
783
+ onStart: () => {
784
+ this.activity.markPlaybackStarted();
785
+ this.params.onStart();
786
+ if (this.activity.snapshot().streamEnding) {
787
+ this.scheduleWatchdog("player-start");
788
+ if (!this.drainHandler) this.stream.end();
789
+ }
790
+ },
791
+ onIdle: () => this.close(this.failed ? "output-pipeline-error" : "player-idle"),
792
+ onBargeIn: this.params.onBargeIn,
793
+ onError: this.params.onError
794
+ };
795
+ this.params.player.enqueue(this.request);
796
+ }
797
+ createResource() {
798
+ const voiceSdk = loadDiscordVoiceSdk();
799
+ const opusStream = createDiscordOpusEncodeStream();
800
+ opusStream.once("error", () => {
801
+ this.failed = true;
802
+ });
803
+ pipeline(this.stream, opusStream, (error) => {
804
+ if (!error || this.closed) return;
805
+ logger$3.warn(`discord voice: realtime output pipeline failed ${this.params.logContext}: ${formatErrorMessage(error)}`);
806
+ this.close("output-pipeline-error");
807
+ });
808
+ const buffered = Buffer.concat(this.buffers, this.bufferedBytes);
809
+ this.buffers = [];
810
+ this.bufferedBytes = 0;
811
+ if (buffered.length > 0 && !this.stream.write(buffered)) this.waitForDrain();
812
+ const resource = voiceSdk.createAudioResource(opusStream, { inputType: voiceSdk.StreamType.Opus });
813
+ const read = resource.read.bind(resource);
814
+ resource.read = () => {
815
+ let packet;
816
+ try {
817
+ packet = read();
818
+ if (!packet && opusStream.flushPartialFrame()) packet = read();
819
+ if (packet) this.playedPcmBytes += opusStream.takePcmBytes(packet);
820
+ } catch (error) {
821
+ opusStream.destroy(error instanceof Error ? error : new Error(formatErrorMessage(error)));
822
+ return null;
823
+ }
824
+ if (this.playbackMarks.length > 0) queueMicrotask(() => this.acknowledgePlayedMarks());
825
+ return packet;
826
+ };
827
+ return resource;
828
+ }
829
+ acknowledgePlayedMarks() {
830
+ try {
831
+ while (!this.closed) {
832
+ const mark = this.playbackMarks[0];
833
+ if (!mark || mark.endBytes > this.playedPcmBytes) return;
834
+ this.playbackMarks.shift();
835
+ mark.acknowledge();
836
+ }
837
+ } catch (error) {
838
+ this.params.onError(error);
839
+ }
840
+ }
841
+ waitForDrain() {
842
+ if (this.drainHandler || this.closed) return;
843
+ logger$3.info(`discord voice: realtime audio playback buffering ${this.params.logContext} bufferedBytes=${this.stream.writableLength + this.stream.readableLength}`);
844
+ this.drainHandler = () => {
845
+ this.drainHandler = void 0;
846
+ if (this.closed) return;
847
+ let refreshWatchdog = this.activity.snapshot().streamEnding;
848
+ while (this.buffers.length > 0) {
849
+ const buffered = this.buffers.shift();
850
+ if (!buffered) break;
851
+ this.bufferedBytes -= buffered.length;
852
+ const writable = this.stream.write(buffered);
853
+ if (refreshWatchdog) {
854
+ this.scheduleWatchdog("output-drain");
855
+ refreshWatchdog = false;
856
+ }
857
+ if (!writable) {
858
+ this.waitForDrain();
859
+ return;
860
+ }
861
+ }
862
+ if (this.activity.snapshot().streamEnding) this.stream.end();
863
+ };
864
+ this.stream.once("drain", this.drainHandler);
865
+ }
866
+ scheduleWatchdog(reason) {
867
+ this.clearWatchdog();
868
+ const timeoutMs = this.activity.playbackWatchdogDelayMs({
869
+ marginMs: DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS,
870
+ minMs: DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS
871
+ });
872
+ if (timeoutMs === void 0) return;
873
+ this.watchdog = setTimeout(() => {
874
+ this.watchdog = void 0;
875
+ logger$3.warn(`discord voice: realtime audio playback watchdog fired reason=${reason} ${this.params.logContext} audioMs=${Math.floor(this.activity.snapshot().audioMs)} elapsedMs=${this.activity.elapsedPlaybackMs()}`);
876
+ this.close("playback-watchdog");
877
+ }, timeoutMs);
878
+ }
879
+ clearWatchdog() {
880
+ clearTimeout(this.watchdog);
881
+ this.watchdog = void 0;
882
+ }
883
+ };
884
+ //#endregion
577
885
  //#region extensions/discord/src/voice/realtime-playback.ts
578
- const logger$1 = createSubsystemLogger("discord/voice");
886
+ const logger$2 = createSubsystemLogger("discord/voice");
579
887
  const DISCORD_REALTIME_CONTROL_SPEECH_DEDUPE_MS = 5e3;
580
- const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 3e3;
581
- const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_MESSAGES = 32;
888
+ const DISCORD_REALTIME_MAX_RETAINED_RESPONSES = 32;
582
889
  const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_BYTES = 32768;
583
890
  const DISCORD_REALTIME_WAKE_ACKS = [
584
891
  "Yeah.",
@@ -586,49 +893,45 @@ const DISCORD_REALTIME_WAKE_ACKS = [
586
893
  "Got it.",
587
894
  "One sec."
588
895
  ];
589
- const DISCORD_RAW_PCM_FRAME_BYTES = 3840;
590
- const DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES = 25;
591
- const DISCORD_REALTIME_MAX_PENDING_OUTPUT_BYTES = DISCORD_RAW_PCM_FRAME_BYTES * 6e3;
896
+ const DISCORD_REALTIME_MAX_PENDING_OUTPUT_BYTES = 2304e4;
592
897
  function normalizeControlSpeechText(text) {
593
898
  return text.toLowerCase().replace(/\s+/g, " ").trim();
594
899
  }
595
900
  var DiscordRealtimePlayback = class {
596
901
  constructor(params) {
597
902
  this.params = params;
598
- this.outputStream = null;
599
- this.outputPacedBuffers = [];
600
- this.outputPacedBytes = 0;
903
+ this.outputs = /* @__PURE__ */ new Set();
904
+ this.generatingItems = /* @__PURE__ */ new Map();
905
+ this.responseAudio = "completed";
601
906
  this.queuedExactSpeechMessages = [];
602
907
  this.exactSpeechState = { status: "idle" };
603
908
  this.wakeNameAckIndex = 0;
604
- this.playerIdleHandler = () => {
605
- const hadOutputAudio = this.isOutputAudioActive();
606
- this.resetOutputStream("player-idle");
607
- if (hadOutputAudio) this.completeExactSpeechResponse("player-idle");
608
- };
609
- }
610
- attachPlayer() {
611
- const voiceSdk = loadDiscordVoiceSdk();
612
- this.params.entry.player.on(voiceSdk.AudioPlayerStatus.Idle, this.playerIdleHandler);
909
+ this.unregisterPlayerLane = this.params.player.registerLane({
910
+ hasOutput: () => this.isOutputAudioActive(),
911
+ onBargeIn: (reason) => this.handleBargeIn(reason),
912
+ cancelForControl: () => this.cancelForControl()
913
+ });
613
914
  }
614
915
  close() {
916
+ this.unregisterPlayerLane();
615
917
  this.queuedExactSpeechMessages = [];
616
918
  this.exactSpeechState = { status: "idle" };
617
919
  this.clearOutputAudio("session-close");
618
- const voiceSdk = loadDiscordVoiceSdk();
619
- this.params.entry.player.off(voiceSdk.AudioPlayerStatus.Idle, this.playerIdleHandler);
620
920
  }
621
921
  handleBargeIn(reason = "barge-in") {
622
922
  if (!this.isBargeInEnabled()) {
623
- logger$1.info(`discord voice: realtime barge-in ignored reason=${reason} bargeIn=false guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}`);
923
+ logger$2.info(`discord voice: realtime barge-in ignored reason=${reason} bargeIn=false guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}`);
624
924
  return;
625
925
  }
626
926
  if (!this.hasInterruptibleOutputAudio()) {
627
- logger$1.info(`discord voice: realtime barge-in ignored reason=${reason} outputActive=false guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} playbackChunks=${this.params.harness.outputActivity.snapshot().chunks}`);
927
+ logger$2.info(`discord voice: realtime barge-in ignored reason=${reason} outputActive=false guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} playbackChunks=${this.params.harness.outputActivity.snapshot().chunks}`);
628
928
  return;
629
929
  }
630
- logger$1.info(`discord voice: realtime barge-in requested reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()} playbackChunks=${this.params.harness.outputActivity.snapshot().chunks}`);
631
- this.params.harness.handleBargeIn({ audioPlaybackActive: true }, () => {});
930
+ logger$2.info(`discord voice: realtime barge-in requested reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()} playbackChunks=${this.params.harness.outputActivity.snapshot().chunks}`);
931
+ const bridge = this.params.bridge();
932
+ this.params.harness.handleBargeIn({ audioPlaybackActive: true }, () => {
933
+ if (!bridge?.bridge.handleBargeIn) this.clearOutputAudio(reason);
934
+ });
632
935
  }
633
936
  isBargeInEnabled() {
634
937
  if (this.params.wakeNameRequired()) return false;
@@ -641,67 +944,89 @@ var DiscordRealtimePlayback = class {
641
944
  }
642
945
  hasInterruptibleOutputAudio() {
643
946
  this.params.bridge()?.setMediaTimestamp(this.outputAudioMs());
644
- const streamActive = Boolean(this.outputStream && !this.outputStream.destroyed);
645
- return this.params.harness.outputActivity.isInterruptible(streamActive);
947
+ return this.isOutputAudioActive();
948
+ }
949
+ getPlaybackState() {
950
+ const items = /* @__PURE__ */ new Set();
951
+ for (const output of this.outputs) {
952
+ const outputItems = output.playbackItems();
953
+ if (outputItems.some((item) => this.generatingItems.get(item.itemId) === item)) for (const item of this.generatingItems.values()) items.add(item);
954
+ for (const item of outputItems) items.add(item);
955
+ }
956
+ for (const item of this.generatingItems.values()) items.add(item);
957
+ return Array.from(items, (item) => ({
958
+ ...item,
959
+ audioEndMs: Math.floor(item.audioEndMs)
960
+ }));
646
961
  }
647
- sendOutputAudio(realtimePcm24kMono) {
962
+ beginResponse() {
963
+ if (this.responseAudio === "completed") this.responseAudio = "accepting";
964
+ }
965
+ sendOutputMark(acknowledge) {
966
+ if (!this.params.stopped() && this.responseAudio === "accepting") this.generatingOutput?.markPlayback(acknowledge);
967
+ }
968
+ sendOutputAudio(realtimePcm24kMono, metadata) {
648
969
  this.params.markProviderGenerationObserved();
649
- if (this.params.stopped()) return;
970
+ if (this.params.stopped() || this.responseAudio === "discarding") return;
650
971
  const discordPcm = convertRealtimePcm24kMonoToDiscordPcm48kStereo(realtimePcm24kMono);
651
972
  if (discordPcm.length === 0) return;
652
973
  this.params.bridge()?.setMediaTimestamp(this.outputAudioMs());
653
- if (this.params.harness.outputActivity.snapshot().streamEnding) {
654
- logVoiceVerbose(`realtime output audio ignored after stream ending: guild ${this.params.entry.guildId} channel ${this.params.entry.channelId}`);
974
+ const pendingBytes = Array.from(this.outputs).reduce((total, output) => total + output.pendingBytes(), 0);
975
+ if (discordPcm.length > DISCORD_REALTIME_MAX_PENDING_OUTPUT_BYTES - pendingBytes || !this.generatingOutput && this.outputs.size >= DISCORD_REALTIME_MAX_RETAINED_RESPONSES) {
976
+ this.stopAfterPlaybackFailure("output-audio-overflow", /* @__PURE__ */ new Error(`Discord realtime audio playback overflow: responses=${this.outputs.size} pendingBytes=${pendingBytes} incomingBytes=${discordPcm.length}`));
655
977
  return;
656
978
  }
657
- const stream = this.ensureOutputStream();
658
- if (this.exactSpeechState.status === "active") this.exactSpeechState = {
659
- ...this.exactSpeechState,
660
- audioStarted: true
661
- };
662
- this.params.harness.recordOutputAudio(realtimePcm24kMono, {
979
+ this.beginResponse();
980
+ const output = this.generatingOutput ?? this.createOutput();
981
+ const activity = {
663
982
  audioMs: realtimeVoiceAudioDurationMs(REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, realtimePcm24kMono.byteLength),
664
983
  sourceAudioBytes: realtimePcm24kMono.length,
665
984
  sinkAudioBytes: discordPcm.length
666
- });
667
- this.queueOutputAudio(stream, discordPcm);
985
+ };
986
+ let item;
987
+ if (metadata) {
988
+ item = this.generatingItems.get(metadata.itemId);
989
+ if (!item) {
990
+ item = {
991
+ itemId: metadata.itemId,
992
+ audioEndMs: 0
993
+ };
994
+ this.generatingItems.set(metadata.itemId, item);
995
+ }
996
+ }
997
+ this.params.harness.recordOutputAudio(realtimePcm24kMono, activity);
998
+ output.append(discordPcm, activity, item);
668
999
  }
669
1000
  clearOutputAudio(reason = "clear") {
670
- this.resetOutputStream(reason);
671
- this.params.entry.player.stop(true);
672
- }
673
- finishOutputAudioStream(reason, { playBuffered = true } = {}) {
674
- const stream = this.outputStream;
675
- if (!stream || stream.destroyed || this.params.harness.outputActivity.snapshot().streamEnding) return;
676
- this.params.harness.outputActivity.markStreamEnding();
677
- logger$1.info(`discord voice: realtime audio playback finishing reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${this.outputAudioMs()} chunks=${this.params.harness.outputActivity.snapshot().chunks}`);
678
- if (playBuffered) {
679
- this.startOutputPlayback(stream);
680
- this.scheduleOutputPlaybackWatchdog(reason, stream);
681
- } else {
682
- this.resetOutputStream(reason);
683
- this.params.entry.player.stop(true);
684
- this.completeExactSpeechResponse(reason);
685
- return;
686
- }
687
- if (!this.outputDrainHandler) stream.end();
1001
+ if (this.responseAudio === "accepting") this.responseAudio = "discarding";
1002
+ this.generatingOutput = void 0;
1003
+ this.generatingItems.clear();
1004
+ const outputs = Array.from(this.outputs);
1005
+ this.outputs.clear();
1006
+ for (const output of outputs.toReversed()) output.close(reason);
1007
+ this.params.harness.outputActivity.reset();
1008
+ this.completeExactSpeechResponse(reason);
688
1009
  }
689
1010
  handleResponseDone(outcome) {
690
- if (this.exactSpeechState.status === "active" && !this.exactSpeechState.audioStarted) this.completeExactSpeechResponse(outcome.status);
691
- this.finishOutputAudioStream(outcome.status, { playBuffered: outcome.status === "completed" });
1011
+ const output = this.generatingOutput;
1012
+ this.generatingOutput = void 0;
1013
+ this.generatingItems.clear();
1014
+ this.responseAudio = "completed";
1015
+ output?.finish(outcome.status, outcome.status === "completed");
1016
+ this.completeExactSpeechResponse(outcome.status);
692
1017
  }
693
1018
  enqueueExactSpeechMessage(text) {
694
1019
  if (this.params.stopped() || !text.trim()) return;
695
1020
  const retainedMessages = this.queuedExactSpeechMessages.length + (this.exactSpeechState.status === "active" ? 1 : 0);
696
1021
  const retainedBytes = this.queuedExactSpeechMessages.reduce((total, message) => total + Buffer.byteLength(message, "utf8"), 0) + Buffer.byteLength(this.exactSpeechState.status === "active" ? this.exactSpeechState.message : "", "utf8");
697
1022
  const incomingBytes = Buffer.byteLength(text, "utf8");
698
- if (retainedMessages >= DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_MESSAGES || retainedBytes + incomingBytes > DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_BYTES) {
699
- this.stopAfterOverflow("exact-speech-overflow", /* @__PURE__ */ new Error(`Discord realtime exact speech overflow: retained=${retainedMessages} retainedBytes=${retainedBytes} incomingBytes=${incomingBytes}`));
1023
+ if (retainedMessages >= DISCORD_REALTIME_MAX_RETAINED_RESPONSES || retainedBytes + incomingBytes > DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_BYTES) {
1024
+ this.stopAfterPlaybackFailure("exact-speech-overflow", /* @__PURE__ */ new Error(`Discord realtime exact speech overflow: retained=${retainedMessages} retainedBytes=${retainedBytes} incomingBytes=${incomingBytes}`));
700
1025
  return;
701
1026
  }
702
- if (!this.params.bridgeReady() || this.exactSpeechState.status === "active" || this.hasInterruptibleOutputAudio()) {
1027
+ if (!this.params.bridgeReady() || this.responseAudio !== "completed" || this.exactSpeechState.status === "active" || this.hasInterruptibleOutputAudio()) {
703
1028
  this.queuedExactSpeechMessages.push(text);
704
- logger$1.info(`discord voice: realtime exact speech queued guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} queued=${this.queuedExactSpeechMessages.length} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()}`);
1029
+ logger$2.info(`discord voice: realtime exact speech queued guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} queued=${this.queuedExactSpeechMessages.length} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()}`);
705
1030
  return;
706
1031
  }
707
1032
  this.sendExactSpeechMessage(text);
@@ -710,32 +1035,27 @@ var DiscordRealtimePlayback = class {
710
1035
  return [...this.exactSpeechState.status === "active" ? [this.exactSpeechState.message] : [], ...this.queuedExactSpeechMessages];
711
1036
  }
712
1037
  drainQueuedExactSpeechMessages(reason) {
713
- if (this.params.stopped() || !this.params.bridgeReady() || this.exactSpeechState.status === "active" || this.queuedExactSpeechMessages.length === 0 || this.hasInterruptibleOutputAudio()) return;
1038
+ if (this.params.stopped() || !this.params.bridgeReady() || this.responseAudio !== "completed" || this.exactSpeechState.status === "active" || this.queuedExactSpeechMessages.length === 0 || this.hasInterruptibleOutputAudio()) return;
714
1039
  const next = this.queuedExactSpeechMessages.shift();
715
1040
  if (!next) return;
716
- logger$1.info(`discord voice: realtime exact speech dequeued reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} queued=${this.queuedExactSpeechMessages.length}`);
1041
+ logger$2.info(`discord voice: realtime exact speech dequeued reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} queued=${this.queuedExactSpeechMessages.length}`);
717
1042
  this.sendExactSpeechMessage(next);
718
1043
  }
719
1044
  sendWakeNameAck(result) {
720
1045
  if (!result.allowed || this.params.stopped() || this.exactSpeechState.status === "active") return;
721
- if (this.hasInterruptibleOutputAudio()) {
722
- logger$1.info(`discord voice: realtime wake-name ack skipped outputActive=true voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
1046
+ if (this.params.player.isActive() || this.hasInterruptibleOutputAudio()) {
1047
+ logger$2.info(`discord voice: realtime wake-name ack skipped outputActive=true voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
723
1048
  return;
724
1049
  }
725
1050
  const ack = DISCORD_REALTIME_WAKE_ACKS[this.wakeNameAckIndex % DISCORD_REALTIME_WAKE_ACKS.length];
726
1051
  this.wakeNameAckIndex += 1;
727
- logger$1.info(`discord voice: realtime wake-name ack canonical=${result.activationName} heard=${result.heardName} match=${result.match} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
1052
+ logger$2.info(`discord voice: realtime wake-name ack canonical=${result.activationName} heard=${result.heardName} match=${result.match} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`);
728
1053
  this.enqueueExactSpeechMessage(ack ?? "Yeah.");
729
1054
  }
730
1055
  speakControlResult(text) {
731
1056
  const trimmed = text.trim();
732
1057
  if (this.params.stopped() || !trimmed) return;
733
- this.queuedExactSpeechMessages = [];
734
- this.completeExactSpeechResponse("active-run-control", { drain: false });
735
- this.params.harness.handleBargeIn({
736
- audioPlaybackActive: true,
737
- force: true
738
- }, () => this.clearOutputAudio("active-run-control"));
1058
+ this.params.player.cancelForControl();
739
1059
  this.lastControlSpeech = {
740
1060
  normalizedText: normalizeControlSpeechText(trimmed),
741
1061
  sentAt: Date.now(),
@@ -743,6 +1063,15 @@ var DiscordRealtimePlayback = class {
743
1063
  };
744
1064
  this.enqueueExactSpeechMessage(trimmed);
745
1065
  }
1066
+ cancelForControl() {
1067
+ this.queuedExactSpeechMessages = [];
1068
+ this.exactSpeechState = { status: "idle" };
1069
+ if (this.responseAudio === "completed" && this.outputs.size === 0) return;
1070
+ this.params.harness.handleBargeIn({
1071
+ audioPlaybackActive: true,
1072
+ force: true
1073
+ }, () => this.clearOutputAudio("active-run-control"));
1074
+ }
746
1075
  suppressDuplicateControlSpeech(text) {
747
1076
  const recent = this.lastControlSpeech;
748
1077
  if (!recent) return;
@@ -753,7 +1082,7 @@ var DiscordRealtimePlayback = class {
753
1082
  if (normalizeControlSpeechText(text) !== recent.normalizedText) return;
754
1083
  recent.assistantTranscriptCount += 1;
755
1084
  if (recent.assistantTranscriptCount <= 1) return;
756
- logger$1.info(`discord voice: realtime duplicate active-run control speech suppressed guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}`);
1085
+ logger$2.info(`discord voice: realtime duplicate active-run control speech suppressed guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}`);
757
1086
  this.params.harness.handleBargeIn({
758
1087
  audioPlaybackActive: true,
759
1088
  force: true
@@ -761,168 +1090,73 @@ var DiscordRealtimePlayback = class {
761
1090
  }
762
1091
  resetProviderContinuity(reason) {
763
1092
  this.lastControlSpeech = void 0;
764
- const replayExactSpeech = this.exactSpeechState.status === "active" && !this.params.harness.outputActivity.snapshot().playbackStarted ? this.exactSpeechState.message : void 0;
1093
+ const replayExactSpeech = this.exactSpeechState.status === "active" && !this.exactSpeechState.output?.activity.snapshot().playbackStarted ? this.exactSpeechState.message : void 0;
765
1094
  this.exactSpeechState = { status: "idle" };
766
1095
  if (replayExactSpeech) this.queuedExactSpeechMessages.unshift(replayExactSpeech);
1096
+ this.responseAudio = "discarding";
767
1097
  this.params.harness.flushOutput(() => this.clearOutputAudio(reason));
1098
+ this.responseAudio = "completed";
768
1099
  this.params.harness.finishOutputAudio(reason);
769
1100
  }
770
1101
  outputAudioMs() {
771
1102
  return Math.floor(this.params.harness.outputActivity.snapshot().audioMs);
772
1103
  }
773
1104
  isOutputAudioActive() {
774
- return this.params.harness.outputActivity.isActive(Boolean(this.outputStream && !this.outputStream.destroyed));
775
- }
776
- currentOutputStream() {
777
- return this.outputStream;
1105
+ return this.outputs.size > 0 || this.generatingItems.size > 0;
778
1106
  }
779
- stopAfterOverflow(reason, error) {
1107
+ stopAfterPlaybackFailure(reason, error) {
780
1108
  this.params.stopTerminally();
781
1109
  this.queuedExactSpeechMessages = [];
782
1110
  this.exactSpeechState = { status: "idle" };
783
1111
  this.clearOutputAudio(reason);
784
1112
  this.params.onTerminalError(error);
785
1113
  }
786
- ensureOutputStream() {
787
- if (this.outputStream && !this.outputStream.destroyed && !this.outputStream.writableEnded) return this.outputStream;
788
- const stream = new PassThrough({ highWaterMark: DISCORD_RAW_PCM_FRAME_BYTES * 128 });
789
- this.outputStream = stream;
790
- this.outputPacedBuffers = [];
791
- this.outputPacedBytes = 0;
792
- this.params.harness.outputActivity.markStreamOpened();
793
- stream.once("close", () => {
794
- if (this.outputStream !== stream || this.params.harness.outputActivity.snapshot().playbackStarted) return;
795
- this.resetOutputStream("stream-close");
796
- this.completeExactSpeechResponse("stream-close");
797
- });
798
- return stream;
799
- }
800
- queueOutputAudio(stream, discordPcm) {
801
- const pendingBytes = this.outputPacedBytes + stream.writableLength + stream.readableLength;
802
- if (discordPcm.length > DISCORD_REALTIME_MAX_PENDING_OUTPUT_BYTES - pendingBytes) {
803
- this.stopAfterOverflow("output-audio-overflow", /* @__PURE__ */ new Error(`Discord realtime audio playback overflow: pendingBytes=${pendingBytes} incomingBytes=${discordPcm.length}`));
804
- return;
805
- }
806
- if (this.params.harness.outputActivity.snapshot().playbackStarted && !this.outputDrainHandler) {
807
- if (!stream.write(discordPcm)) this.waitForOutputDrain(stream);
808
- return;
809
- }
810
- this.outputPacedBuffers.push(discordPcm);
811
- this.outputPacedBytes += discordPcm.length;
812
- if (!this.outputDrainHandler && this.outputPacedBytes >= DISCORD_RAW_PCM_FRAME_BYTES * DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES) this.startOutputPlayback(stream);
813
- }
814
- waitForOutputDrain(stream) {
815
- if (this.outputDrainHandler || this.outputStream !== stream) return;
816
- logger$1.info(`discord voice: realtime audio playback buffering guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} bufferedBytes=${stream.writableLength + stream.readableLength}`);
817
- this.outputDrainHandler = () => {
818
- if (this.params.stopped() || this.outputStream !== stream || stream.destroyed) return;
819
- this.outputDrainHandler = void 0;
820
- let refreshWatchdog = this.params.harness.outputActivity.snapshot().streamEnding;
821
- while (this.outputPacedBuffers.length > 0) {
822
- const buffered = this.outputPacedBuffers.shift();
823
- if (!buffered) break;
824
- this.outputPacedBytes -= buffered.length;
825
- const writable = stream.write(buffered);
826
- if (refreshWatchdog) {
827
- this.scheduleOutputPlaybackWatchdog("output-drain", stream);
828
- refreshWatchdog = false;
829
- }
830
- if (!writable) {
831
- this.waitForOutputDrain(stream);
832
- return;
1114
+ createOutput() {
1115
+ const logContext = `guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}`;
1116
+ const output = new DiscordRealtimeOutput({
1117
+ player: this.params.player,
1118
+ logContext,
1119
+ onStart: () => {
1120
+ this.params.harness.outputActivity.markPlaybackStarted();
1121
+ const config = this.params.realtimeConfig();
1122
+ logger$2.info(`discord voice: realtime audio playback started ${logContext} mode=${this.params.mode} model=${config?.model ?? "provider-default"} voice=${config?.speakerVoice ?? config?.speakerVoiceId ?? "provider-default"}`);
1123
+ },
1124
+ onClose: (closed, reason) => {
1125
+ if (!this.outputs.delete(closed)) return;
1126
+ if (this.generatingOutput === closed) {
1127
+ this.generatingOutput = void 0;
1128
+ if (reason !== "player-idle") this.responseAudio = "discarding";
833
1129
  }
834
- }
835
- if (this.params.harness.outputActivity.snapshot().streamEnding) stream.end();
836
- };
837
- stream.once("drain", this.outputDrainHandler);
838
- }
839
- startOutputPlayback(stream) {
840
- if (this.params.harness.outputActivity.snapshot().playbackStarted || stream.destroyed) return;
841
- const voiceSdk = loadDiscordVoiceSdk();
842
- const opusStream = createDiscordOpusEncodeStream();
843
- pipeline(stream, opusStream, (err) => {
844
- if (!err || this.outputStream !== stream) return;
845
- logger$1.warn(`discord voice: realtime output pipeline failed guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}: ${formatErrorMessage(err)}`);
846
- this.clearOutputAudio("output-pipeline-error");
847
- this.completeExactSpeechResponse("output-pipeline-error");
848
- });
849
- const buffered = Buffer.concat(this.outputPacedBuffers, this.outputPacedBytes);
850
- this.outputPacedBuffers = [];
851
- this.outputPacedBytes = 0;
852
- if (buffered.length > 0 && !stream.write(buffered)) this.waitForOutputDrain(stream);
853
- const resource = voiceSdk.createAudioResource(opusStream, { inputType: voiceSdk.StreamType.Opus });
854
- this.params.entry.player.play(resource);
855
- this.params.harness.outputActivity.markPlaybackStarted();
856
- const realtimeConfig = this.params.realtimeConfig();
857
- logger$1.info(`discord voice: realtime audio playback started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} mode=${this.params.mode} model=${realtimeConfig?.model ?? "provider-default"} voice=${realtimeConfig?.speakerVoice ?? realtimeConfig?.speakerVoiceId ?? "provider-default"}`);
858
- }
859
- resetOutputStream(reason = "reset") {
860
- const stream = this.outputStream;
861
- this.clearOutputPlaybackWatchdog();
862
- this.logOutputAudioStopped(reason);
863
- this.outputStream = null;
864
- this.outputPacedBuffers = [];
865
- this.outputPacedBytes = 0;
866
- if (this.outputDrainHandler) {
867
- stream?.off("drain", this.outputDrainHandler);
868
- this.outputDrainHandler = void 0;
869
- }
870
- this.params.harness.outputActivity.reset();
871
- stream?.end();
872
- stream?.destroy();
873
- }
874
- scheduleOutputPlaybackWatchdog(reason, stream) {
875
- this.clearOutputPlaybackWatchdog();
876
- const timeoutMs = this.params.harness.outputActivity.playbackWatchdogDelayMs({
877
- marginMs: DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS,
878
- minMs: DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS
1130
+ if (this.outputs.size === 0) this.params.harness.outputActivity.reset();
1131
+ this.completeExactSpeechResponse(reason);
1132
+ },
1133
+ onBargeIn: (reason) => this.handleBargeIn(reason),
1134
+ onError: (error) => this.stopAfterPlaybackFailure("output-playback-error", error instanceof Error ? error : new Error(formatErrorMessage(error)))
879
1135
  });
880
- if (timeoutMs === void 0) return;
881
- this.outputPlaybackWatchdog = setTimeout(() => {
882
- this.outputPlaybackWatchdog = void 0;
883
- if (this.outputStream && this.outputStream !== stream) return;
884
- if (!this.outputStream && !this.isOutputAudioActive()) {
885
- this.completeExactSpeechResponse("playback-watchdog");
886
- return;
887
- }
888
- logger$1.warn(`discord voice: realtime audio playback watchdog fired reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${this.outputAudioMs()} elapsedMs=${this.params.harness.outputActivity.elapsedPlaybackMs()}`);
889
- this.clearOutputAudio("playback-watchdog");
890
- this.completeExactSpeechResponse("playback-watchdog");
891
- }, timeoutMs);
892
- }
893
- clearOutputPlaybackWatchdog() {
894
- if (!this.outputPlaybackWatchdog) return;
895
- clearTimeout(this.outputPlaybackWatchdog);
896
- this.outputPlaybackWatchdog = void 0;
1136
+ if (this.outputs.size === 0) this.params.harness.outputActivity.markStreamOpened();
1137
+ this.outputs.add(output);
1138
+ this.generatingOutput = output;
1139
+ if (this.exactSpeechState.status === "active") this.exactSpeechState.output ??= output;
1140
+ return output;
897
1141
  }
898
1142
  sendExactSpeechMessage(text) {
899
1143
  if (this.params.stopped() || !text.trim()) return;
900
1144
  this.exactSpeechState = {
901
1145
  status: "active",
902
- message: text,
903
- audioStarted: false
1146
+ message: text
904
1147
  };
1148
+ this.beginResponse();
905
1149
  this.params.bridge()?.sendUserMessage(this.params.buildSpeakExactMessage(text));
906
1150
  }
907
- completeExactSpeechResponse(reason, options) {
908
- if (this.exactSpeechState.status === "idle" && this.queuedExactSpeechMessages.length === 0) return;
1151
+ completeExactSpeechResponse(reason) {
1152
+ if (this.responseAudio !== "completed" || this.outputs.size > 0) return;
909
1153
  this.exactSpeechState = { status: "idle" };
910
- if (options?.drain === false) return;
911
1154
  this.drainQueuedExactSpeechMessages(reason);
912
1155
  }
913
- logOutputAudioStopped(reason) {
914
- const activity = this.params.harness.outputActivity.snapshot();
915
- const audioMs = Math.floor(activity.audioMs);
916
- const chunks = activity.chunks;
917
- const discordBytes = activity.sinkAudioBytes;
918
- const realtimeBytes = activity.sourceAudioBytes;
919
- const elapsedMs = this.params.harness.outputActivity.elapsedPlaybackMs();
920
- if (this.outputStream || chunks > 0 || audioMs > 0) logger$1.info(`discord voice: realtime audio playback stopped reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${audioMs} elapsedMs=${elapsedMs} chunks=${chunks} discordBytes=${discordBytes} realtimeBytes=${realtimeBytes}`);
921
- }
922
1156
  };
923
1157
  //#endregion
924
- //#region extensions/discord/src/voice/realtime-session.runtime.ts
925
- const logger = createSubsystemLogger("discord/voice");
1158
+ //#region extensions/discord/src/voice/realtime-speaker-session.ts
1159
+ const logger$1 = createSubsystemLogger("discord/voice");
926
1160
  const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 6e4;
927
1161
  const discordRealtimeTalkPayload = () => ({});
928
1162
  const DISCORD_REALTIME_VERBOSE_OMITTED_EVENTS = /* @__PURE__ */ new Set([
@@ -958,7 +1192,7 @@ function readProviderConfigString(config, key) {
958
1192
  function isDiscordAgentProxyVoiceMode(mode) {
959
1193
  return mode === "agent-proxy";
960
1194
  }
961
- var DiscordRealtimeVoiceSession = class {
1195
+ var DiscordRealtimeSpeakerSession = class {
962
1196
  constructor(params) {
963
1197
  this.params = params;
964
1198
  this.bridge = null;
@@ -972,9 +1206,13 @@ var DiscordRealtimeVoiceSession = class {
972
1206
  this.wakeNames = [];
973
1207
  this.providerGenerationObserved = false;
974
1208
  this.providerContinuityEpoch = 0;
1209
+ this.captures = /* @__PURE__ */ new Set();
1210
+ this.inputOpen = true;
1211
+ this.activeOperations = 0;
1212
+ this.lastActivityAt = Date.now();
975
1213
  this.harness = createRealtimeVoiceSessionHarness({
976
1214
  talk: {
977
- sessionId: `discord:${this.params.entry.voiceSessionKey}:realtime`,
1215
+ sessionId: this.params.sessionId,
978
1216
  mode: "realtime",
979
1217
  transport: "gateway-relay",
980
1218
  brain: "agent-consult"
@@ -1001,6 +1239,7 @@ var DiscordRealtimeVoiceSession = class {
1001
1239
  surfaceLabel: "the Discord voice channel"
1002
1240
  }),
1003
1241
  entry: this.params.entry,
1242
+ player: this.params.player,
1004
1243
  harness: this.harness,
1005
1244
  markProviderGenerationObserved: () => this.markProviderGenerationObserved(),
1006
1245
  mode: this.params.mode,
@@ -1018,6 +1257,11 @@ var DiscordRealtimeVoiceSession = class {
1018
1257
  bridge: () => this.bridge,
1019
1258
  entry: this.params.entry,
1020
1259
  getHumanParticipantCount: () => this.humanParticipantCount(),
1260
+ interruptRoomPlayback: () => {
1261
+ if (!this.playback.isBargeInEnabled() || !this.params.player.isActive()) return false;
1262
+ this.params.player.handleBargeIn("active-speaker-audio");
1263
+ return true;
1264
+ },
1021
1265
  onAcceptedTranscript: (text, context, providerEpoch) => this.consults.handleAcceptedTranscript(text, context, providerEpoch),
1022
1266
  playback: this.playback,
1023
1267
  providerEpoch: () => this.providerContinuityEpoch,
@@ -1039,7 +1283,7 @@ var DiscordRealtimeVoiceSession = class {
1039
1283
  isWakeNameRequired: () => this.isWakeNameRequired(),
1040
1284
  playback: this.playback,
1041
1285
  providerEpoch: () => this.providerContinuityEpoch,
1042
- runAgentTurn: this.params.runAgentTurn,
1286
+ runAgentTurn: (turn) => this.trackOperation(() => this.params.runAgentTurn(turn)),
1043
1287
  stopped: () => this.isStopped(),
1044
1288
  turns: this.turns,
1045
1289
  usesRealtimeAgentHandoff: () => this.params.mode === "bidi" || this.consultToolPolicy !== "none",
@@ -1116,40 +1360,49 @@ var DiscordRealtimeVoiceSession = class {
1116
1360
  instructions,
1117
1361
  autoRespondToAudio,
1118
1362
  interruptResponseOnInputAudio,
1119
- markStrategy: "ack-immediately",
1363
+ markStrategy: "transport",
1120
1364
  tools: usesRealtimeAgentHandoff ? resolveRealtimeVoiceAgentConsultTools(toolPolicy, toolPolicy !== "none" ? [REALTIME_VOICE_AGENT_CONTROL_TOOL] : []) : [],
1121
1365
  audioSink: {
1122
1366
  isOpen: () => !this.isStopped(),
1123
- sendAudio: (audio) => this.playback.sendOutputAudio(audio),
1367
+ sendAudio: (audio, metadata) => this.playback.sendOutputAudio(audio, metadata),
1368
+ sendMark: (markName, acknowledge) => {
1369
+ if (acknowledge) this.playback.sendOutputMark(acknowledge);
1370
+ else this.bridge?.acknowledgeMark(markName);
1371
+ },
1372
+ getPlaybackState: () => this.playback.getPlaybackState(),
1124
1373
  clearAudio: () => {
1125
1374
  this.markProviderGenerationObserved();
1126
1375
  this.harness.flushOutput(() => this.playback.clearOutputAudio("provider-clear-audio"));
1127
1376
  }
1128
1377
  },
1129
1378
  onTranscript: (role, text, isFinal) => {
1379
+ if (this.isStopped()) return;
1130
1380
  this.markProviderGenerationObserved();
1131
- if (isFinal && text.trim()) logger.info(`discord voice: realtime ${role} transcript (${text.length} chars): ${formatVoiceLogPreview(text)}`);
1381
+ if (isFinal && text.trim()) logger$1.info(`discord voice: realtime ${role} transcript (${text.length} chars): ${formatVoiceLogPreview(text)}`);
1132
1382
  if (isFinal && role === "assistant") this.playback.suppressDuplicateControlSpeech(text);
1133
1383
  if (role !== "user") return;
1134
1384
  if (!isFinal) {
1135
1385
  this.turns.handlePartialUserTranscript(text);
1136
1386
  return;
1137
1387
  }
1138
- this.turns.handleFinalUserTranscript(text);
1388
+ this.trackOperation(() => this.turns.handleFinalUserTranscript(text)).catch((error) => this.logRealtimeError(formatErrorMessage(error)));
1139
1389
  },
1140
1390
  onToolCall: (event, session) => {
1391
+ if (this.isStopped()) return;
1141
1392
  this.markProviderGenerationObserved();
1142
- return this.consults.handleToolCall(event, session);
1393
+ return this.trackOperation(() => this.consults.handleToolCall(event, session));
1143
1394
  },
1144
1395
  onReady,
1145
1396
  onEvent: (event) => {
1397
+ if (this.isStopped()) return;
1146
1398
  this.handleBridgeEvent(event);
1147
1399
  if (event.direction === "client" && event.type === "session.reconnect.ready") onReady();
1148
1400
  },
1149
1401
  onResponseDone: (outcome) => {
1402
+ if (this.isStopped()) return;
1150
1403
  this.markProviderGenerationObserved();
1151
1404
  this.playback.handleResponseDone(outcome);
1152
- if (outcome.status === "cancelled") logger.info(`discord voice: realtime model interrupt confirmed server:response.done status=cancelled${outcome.reason ? ` reason=${outcome.reason}` : ""}`);
1405
+ if (outcome.status === "cancelled") logger$1.info(`discord voice: realtime model interrupt confirmed server:response.done status=cancelled${outcome.reason ? ` reason=${outcome.reason}` : ""}`);
1153
1406
  else if (outcome.status === "failed" || outcome.status === "incomplete") this.logRealtimeError(outcome.message);
1154
1407
  },
1155
1408
  onError: (error) => this.logRealtimeError(formatErrorMessage(error)),
@@ -1167,8 +1420,7 @@ var DiscordRealtimeVoiceSession = class {
1167
1420
  const resolvedModel = readProviderConfigString(resolved.providerConfig, "model") ?? resolved.provider.defaultModel;
1168
1421
  const resolvedVoice = readProviderConfigString(resolved.providerConfig, "voice");
1169
1422
  const humanParticipantCount = this.humanParticipantCount();
1170
- logger.info(`discord voice: realtime bridge starting mode=${this.params.mode} provider=${resolved.provider.id} model=${resolvedModel ?? "default"} voice=${resolvedVoice ?? "default"} consultPolicy=${consultPolicy} toolPolicy=${toolPolicy} autoRespond=${autoRespondToAudio} wakeNamePolicy=${this.wakeNamePolicy} requireWakeName=${this.isWakeNameRequired(humanParticipantCount)} humanParticipants=${humanParticipantCount} wakeNames=${this.wakeNames.join(",") || "none"} interruptResponse=${interruptResponseOnInputAudio} bargeIn=${bargeIn} minBargeInAudioEndMs=${minBargeInAudioEndMs}`);
1171
- this.playback.attachPlayer();
1423
+ logger$1.info(`discord voice: realtime bridge starting mode=${this.params.mode} provider=${resolved.provider.id} model=${resolvedModel ?? "default"} voice=${resolvedVoice ?? "default"} consultPolicy=${consultPolicy} toolPolicy=${toolPolicy} autoRespond=${autoRespondToAudio} wakeNamePolicy=${this.wakeNamePolicy} requireWakeName=${this.isWakeNameRequired(humanParticipantCount)} humanParticipants=${humanParticipantCount} wakeNames=${this.wakeNames.join(",") || "none"} interruptResponse=${interruptResponseOnInputAudio} bargeIn=${bargeIn} minBargeInAudioEndMs=${minBargeInAudioEndMs}`);
1172
1424
  await this.bridge.connect();
1173
1425
  if (!this.markLifecycleReady(lifecycleGeneration)) {
1174
1426
  this.bridge?.close();
@@ -1176,10 +1428,11 @@ var DiscordRealtimeVoiceSession = class {
1176
1428
  }
1177
1429
  this.markProviderGenerationObserved();
1178
1430
  this.playback.drainQueuedExactSpeechMessages("provider-connected");
1179
- logger.info(`discord voice: realtime bridge ready mode=${this.params.mode} provider=${resolved.provider.id} model=${resolvedModel ?? "default"} voice=${resolvedVoice ?? "default"}`);
1431
+ logger$1.info(`discord voice: realtime bridge ready mode=${this.params.mode} provider=${resolved.provider.id} model=${resolvedModel ?? "default"} voice=${resolvedVoice ?? "default"}`);
1180
1432
  }
1181
1433
  close() {
1182
1434
  this.lifecycle.status = "stopped";
1435
+ this.drain();
1183
1436
  this.providerContinuityEpoch += 1;
1184
1437
  this.flushSuppressedRealtimeErrors();
1185
1438
  this.consults.close();
@@ -1191,7 +1444,46 @@ var DiscordRealtimeVoiceSession = class {
1191
1444
  this.realtimeProviderId = void 0;
1192
1445
  }
1193
1446
  beginSpeakerTurn(context, userId) {
1194
- return this.turns.beginSpeakerTurn(context, userId);
1447
+ if (!this.inputOpen || this.isStopped()) throw new Error("Discord realtime speaker input is closed");
1448
+ const turn = this.turns.beginSpeakerTurn(context, userId);
1449
+ let closed = false;
1450
+ const capture = {
1451
+ sendInputAudio: (audio) => {
1452
+ if (!closed) {
1453
+ this.lastActivityAt = Date.now();
1454
+ turn.sendInputAudio(audio);
1455
+ }
1456
+ },
1457
+ close: () => {
1458
+ if (closed) return;
1459
+ closed = true;
1460
+ this.captures.delete(capture);
1461
+ this.lastActivityAt = Date.now();
1462
+ turn.close();
1463
+ }
1464
+ };
1465
+ this.captures.add(capture);
1466
+ return capture;
1467
+ }
1468
+ drain() {
1469
+ this.inputOpen = false;
1470
+ for (const capture of this.captures) capture.close();
1471
+ }
1472
+ releaseReasonBefore(cutoff) {
1473
+ if (!(this.lastActivityAt < cutoff && this.captures.size === 0 && this.activeOperations === 0 && this.consults.isIdle() && !this.playback.isOutputAudioActive() && this.playback.retainedExactSpeechTexts().length === 0)) return;
1474
+ return this.turns.hasPendingSpeakerAudioContext() ? "input-timeout" : "idle";
1475
+ }
1476
+ notify(text) {
1477
+ this.playback.enqueueExactSpeechMessage(text);
1478
+ }
1479
+ async trackOperation(operation) {
1480
+ this.activeOperations += 1;
1481
+ try {
1482
+ return await operation();
1483
+ } finally {
1484
+ this.activeOperations -= 1;
1485
+ this.lastActivityAt = Date.now();
1486
+ }
1195
1487
  }
1196
1488
  handleBargeIn(reason = "barge-in") {
1197
1489
  this.playback.handleBargeIn(reason);
@@ -1224,14 +1516,16 @@ var DiscordRealtimeVoiceSession = class {
1224
1516
  if (!(event.direction === "client" && event.type === "session.continuity.reset")) this.markProviderGenerationObserved();
1225
1517
  const detail = event.detail ? ` ${event.detail}` : "";
1226
1518
  if (event.direction === "client" && event.type === "session.continuity.reset") this.resetProviderContinuity(event.type);
1519
+ if (event.direction === "server" && event.type === "response.created") this.playback.beginResponse();
1227
1520
  if (event.direction === "server" && event.type === "input_audio_buffer.speech_started") this.turns.resetPartialWakeNameTracking();
1228
1521
  if (shouldLogRealtimeVerboseEvent(event)) logVoiceVerbose(`realtime ${event.direction}:${event.type}${detail}`);
1229
1522
  const interruptionLog = formatRealtimeInterruptionLog(event);
1230
- if (interruptionLog) logger.info(interruptionLog);
1523
+ if (interruptionLog) logger$1.info(interruptionLog);
1231
1524
  const lifecycleLog = formatRealtimeLifecycleLog(event);
1232
- if (lifecycleLog) logger.info(lifecycleLog);
1525
+ if (lifecycleLog) logger$1.info(lifecycleLog);
1233
1526
  }
1234
1527
  markProviderGenerationObserved() {
1528
+ this.lastActivityAt = Date.now();
1235
1529
  this.providerGenerationObserved = true;
1236
1530
  }
1237
1531
  resetProviderContinuity(reason) {
@@ -1255,11 +1549,11 @@ var DiscordRealtimeVoiceSession = class {
1255
1549
  suppressed: 0,
1256
1550
  lastLoggedAt: now
1257
1551
  };
1258
- logger.warn(`discord voice: realtime error: ${message}`);
1552
+ logger$1.warn(`discord voice: realtime error: ${message}`);
1259
1553
  }
1260
1554
  flushSuppressedRealtimeErrors() {
1261
1555
  if (!this.lastRealtimeError || this.lastRealtimeError.suppressed === 0) return;
1262
- logger.warn(`discord voice: suppressed ${this.lastRealtimeError.suppressed} duplicate realtime errors: ${this.lastRealtimeError.message}`);
1556
+ logger$1.warn(`discord voice: suppressed ${this.lastRealtimeError.suppressed} duplicate realtime errors: ${this.lastRealtimeError.message}`);
1263
1557
  this.lastRealtimeError.suppressed = 0;
1264
1558
  }
1265
1559
  };
@@ -1276,4 +1570,120 @@ function buildProviderConfigOverrides(realtimeConfig) {
1276
1570
  return Object.keys(overrides).length > 0 ? overrides : void 0;
1277
1571
  }
1278
1572
  //#endregion
1573
+ //#region extensions/discord/src/voice/realtime-session.runtime.ts
1574
+ const logger = createSubsystemLogger("discord/voice");
1575
+ const MAX_REALTIME_SPEAKERS = 8;
1576
+ const REALTIME_SPEAKER_IDLE_MS = 6e4;
1577
+ /** The room shares its agent and player; each provider connection has one immutable speaker. */
1578
+ var DiscordRealtimeVoiceSession = class {
1579
+ constructor(params) {
1580
+ this.params = params;
1581
+ this.speakers = /* @__PURE__ */ new Map();
1582
+ this.sessions = /* @__PURE__ */ new Set();
1583
+ this.nextSessionId = 0;
1584
+ this.closed = false;
1585
+ this.player = new DiscordRealtimePlayer(params.entry.player);
1586
+ }
1587
+ async connect() {
1588
+ if (this.closed) throw new Error("Discord realtime voice session is closed");
1589
+ const session = this.createSession();
1590
+ this.warmSession = session;
1591
+ await session.connect();
1592
+ if (this.closed) {
1593
+ session.close();
1594
+ return;
1595
+ }
1596
+ this.idleTimer = setInterval(() => this.releaseIdleSpeakers(), REALTIME_SPEAKER_IDLE_MS);
1597
+ this.idleTimer.unref?.();
1598
+ }
1599
+ close() {
1600
+ if (this.closed) return;
1601
+ this.closed = true;
1602
+ clearInterval(this.idleTimer);
1603
+ this.idleTimer = void 0;
1604
+ this.player.close();
1605
+ this.warmSession?.close();
1606
+ this.warmSession = void 0;
1607
+ for (const { session } of this.sessions) session.close();
1608
+ this.speakers.clear();
1609
+ this.sessions.clear();
1610
+ }
1611
+ beginSpeakerTurn(context, userId) {
1612
+ if (this.closed) throw new Error("Discord realtime voice session is closed");
1613
+ for (const previous of this.sessions) if (previous.userId === userId && previous.senderIsOwner !== context.senderIsOwner) this.retireSpeaker(previous, "admission-changed");
1614
+ let speaker = this.speakers.get(userId);
1615
+ if (speaker && speaker.transcripts !== this.params.entry.transcripts) {
1616
+ this.speakers.delete(userId);
1617
+ speaker.session.drain();
1618
+ speaker = void 0;
1619
+ }
1620
+ if (!speaker) {
1621
+ this.releaseIdleSpeakers();
1622
+ if (this.sessions.size >= MAX_REALTIME_SPEAKERS) {
1623
+ const message = "Voice is busy with other speakers. Please try again after their replies.";
1624
+ this.notify(message);
1625
+ throw new Error(message);
1626
+ }
1627
+ const warm = this.warmSession;
1628
+ this.warmSession = void 0;
1629
+ const session = warm ?? this.createSession();
1630
+ speaker = {
1631
+ userId,
1632
+ senderIsOwner: context.senderIsOwner,
1633
+ transcripts: this.params.entry.transcripts,
1634
+ session
1635
+ };
1636
+ this.speakers.set(userId, speaker);
1637
+ this.sessions.add(speaker);
1638
+ if (!warm) session.connect().catch((error) => this.handleSpeakerFailure(session, error));
1639
+ }
1640
+ return speaker.session.beginSpeakerTurn(context, userId);
1641
+ }
1642
+ handleBargeIn(reason = "barge-in") {
1643
+ this.player.handleBargeIn(reason);
1644
+ }
1645
+ isBargeInEnabled() {
1646
+ return (this.warmSession ?? this.sessions.values().next().value?.session)?.isBargeInEnabled() ?? false;
1647
+ }
1648
+ createSession() {
1649
+ const session = new DiscordRealtimeSpeakerSession({
1650
+ ...this.params,
1651
+ player: this.player,
1652
+ sessionId: `discord:${this.params.entry.voiceSessionKey}:realtime:${++this.nextSessionId}`,
1653
+ onTerminalError: (error) => this.handleSpeakerFailure(session, error)
1654
+ });
1655
+ return session;
1656
+ }
1657
+ handleSpeakerFailure(session, error) {
1658
+ if (this.closed) return;
1659
+ if (this.warmSession === session) {
1660
+ this.params.onTerminalError(error instanceof Error ? error : new Error(String(error)));
1661
+ return;
1662
+ }
1663
+ for (const speaker of this.sessions) {
1664
+ if (speaker.session !== session) continue;
1665
+ logger.warn(`discord voice: realtime speaker failed user=${speaker.userId}: ${formatErrorMessage(error)}`);
1666
+ this.retireSpeaker(speaker, "provider-failed");
1667
+ this.notify("I lost a speaker's voice connection. Please try speaking again.");
1668
+ return;
1669
+ }
1670
+ }
1671
+ notify(text) {
1672
+ (this.warmSession ?? this.sessions.values().next().value?.session)?.notify(text);
1673
+ }
1674
+ releaseIdleSpeakers() {
1675
+ const cutoff = Date.now() - REALTIME_SPEAKER_IDLE_MS;
1676
+ for (const speaker of this.sessions) {
1677
+ const reason = speaker.session.releaseReasonBefore(cutoff);
1678
+ if (reason) this.retireSpeaker(speaker, reason);
1679
+ }
1680
+ }
1681
+ retireSpeaker(speaker, reason) {
1682
+ if (this.speakers.get(speaker.userId) === speaker) this.speakers.delete(speaker.userId);
1683
+ this.sessions.delete(speaker);
1684
+ speaker.session.close();
1685
+ logger.info(`discord voice: realtime speaker retired user=${speaker.userId} reason=${reason}${reason === "input-timeout" ? "; idle speaker input expired; speak again to reconnect" : ""}`);
1686
+ }
1687
+ };
1688
+ //#endregion
1279
1689
  export { DiscordRealtimeVoiceSession };