@inetafrica/open-claudia 3.1.13 → 3.1.16

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.1.16 — CI-deflake: no live network in unit tests, CI-safe enforcer deadlines
4
+
5
+ - Identical bot behaviour to v3.1.15 (which never shipped: its pipeline died twice on loaded-runner test flakes — the tag is retired unretagged per release hygiene).
6
+ - `test-telegram-conflict-heal-guard.js` stubbed `_probeTelegramReachable`/`_anyTurnActive`: the wedge-exit assertion used to probe the real api.telegram.org, so a slow/blocked CI egress sent `_heal` down the outage-pause path instead of the exit under test. It also now isolates `OPEN_CLAUDIA_CONFIG_DIR` — the exercised exit path was writing a bogus "polling wedged" `last-exit.json` into the real `~/.open-claudia` on every local test run.
7
+ - `test-provider-enforcer.js` deadlines made CI-safe: the judged-probe service backstop 2000→8000ms (children answer in ms; 2000 was missed by child boot under runner load), the deliberate-timeout probe 2500→5000ms boot headroom.
8
+
9
+ ## v3.1.15 — Voice-note replies speak a conversational summary, not the chat text
10
+
11
+ - Replying to a voice note used to TTS the full formatted chat answer verbatim — bullets, emoji, pipeline IDs and all ("this isn't human hearable"). On voice-reply turns the provider is now asked to end its reply with a dedicated spoken script in `<voice-script>` markers: the chat message keeps the full text answer (script stripped), and only the script — two to four conversational sentences written for the ear — is synthesized into the voice note.
12
+ - If the provider omits the script, a sanitizing fallback (`speechFallback`) strips code blocks, HTML, URLs, emoji, and list markers, caps the speech at the opening sentences, and closes with "The rest is in the text message." — a verbatim readout can no longer reach TTS on text channels.
13
+ - The Kazee call bridge keeps its existing in-call guidance and streams `voiceScript || finalText`, so stray markers are never spoken there either.
14
+ - New extraction/fallback/wiring assertions in `test-media-speech.js`.
15
+
16
+ ## v3.1.14 — Call bridge production hardening: serialized speech, barge-in, group attribution, limits
17
+
18
+ - **TTS is serialized through a queue with cancellation.** Concurrent `speak()` calls used to interleave two 20ms-frame RTP loops over the same sequence/timestamp counters — garbled audio whenever the provider emitted more than one message. Utterances now play strictly one at a time; `cancelSpeech()` (generation-based) stops the current utterance mid-frame and resolves everything queued, and `close()` cancels so no pending `speak()` can hang the adapter.
19
+ - **Barge-in + echo gate.** While the bot is speaking, the VAD threshold rises from −38 to −26 dBFS so the caller's device echoing the bot's own TTS can't trigger STT; genuine speech over the gate cancels playback immediately — you can interrupt her mid-answer like a real call.
20
+ - **Group calls get per-feed pipelines and speaker attribution.** Each subscribed feed now has its own Opus decoder (they're stateful — one shared decoder produced garbage on 2+ feeds), its own VAD, and its own STT stream. Transcripts carry `{feedId, display, participants}`; with more than one participant in the room the provider sees `alice@…: text`, so group calls stop reading as one merged anonymous voice. STT send/commit failures now log and drop the frame instead of killing the whole call (the stream self-heals on the next send).
21
+ - **Calls end on their own when they should.** 30-minute duration cap (`KAZEE_CALL_MAX_MS`) and 2-minute idle timeout (`KAZEE_CALL_IDLE_MS`), both announced with a spoken goodbye; if the caller barges in during the idle goodbye the call stays up. A leaked session can no longer hold Janus/STT resources forever.
22
+ - **First syllables reach STT.** A 240ms pre-onset ring buffer flushes into the STT stream when a turn starts, so VAD onset latency stops clipping the start of every utterance.
23
+ - The five existing call tests plus new `test-kazee-call-session.js` (serialization, cancellation, echo gate, barge-in, prebuffer, attribution, idle/max limits, close semantics) are wired into the main `npm test` chain — call regressions now block every release, not just `test:kazee-call` runs.
24
+
3
25
  ## v3.1.13 — Owner-only Kazee calls reach Open Claudia
4
26
 
5
27
  - Accepts incoming audio calls from the configured Kazee owner only; external, video, malformed, and concurrent calls are rejected before any media session is created.
@@ -43,7 +43,7 @@ class KazeeCallManager {
43
43
  const session = new this.SessionImpl({
44
44
  roomId, chatId, callerId, botUserId: this.adapter.botUserId,
45
45
  janusUrl: this.janusUrl, iceServers: this.iceServers,
46
- onTranscript: (text) => this._transcript(text, payload),
46
+ onTranscript: (text, meta) => this._transcript(text, payload, meta),
47
47
  onEnded: () => { if (this.session === session) this.session = null; },
48
48
  });
49
49
  this.session = session;
@@ -60,9 +60,12 @@ class KazeeCallManager {
60
60
  }
61
61
  }
62
62
 
63
- _transcript(text, incoming) {
63
+ _transcript(text, incoming, meta = {}) {
64
64
  const session = this.session;
65
65
  if (!session || !text) return;
66
+ // In a group room, tell the provider who is talking; 1:1 stays unprefixed.
67
+ const speaker = String(meta.display || "").trim();
68
+ const attributed = (meta.participants || 0) > 1 && speaker ? `${speaker}: ${text}` : text;
66
69
  const callAdapter = Object.create(this.adapter);
67
70
  // Provider narration/previews are useful in chat but must never be spoken
68
71
  // into a call. Only the settled, guarded final answer reaches send().
@@ -83,10 +86,10 @@ class KazeeCallManager {
83
86
  canonicalUserId: canonicalForChannel("kazee", String(incoming.fromUserId)),
84
87
  userId: String(incoming.fromUserId),
85
88
  type: "text",
86
- text,
89
+ text: attributed,
87
90
  messageId: `call-${incoming.roomId}-${Date.now()}`,
88
91
  from: { id: String(incoming.fromUserId), name: incoming.fromUserName || "" },
89
- raw: { ...incoming, source: "kazee-call", ephemeralAudio: true },
92
+ raw: { ...incoming, source: "kazee-call", ephemeralAudio: true, speaker: speaker || undefined, feedId: meta.feedId },
90
93
  });
91
94
  }
92
95
 
@@ -7,8 +7,20 @@ const { SpeechStream, synthesizePcm } = require("./speech-client");
7
7
 
8
8
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
9
 
10
+ const BASE_THRESHOLD_DB = -38;
11
+ // While TTS plays, the caller's device can leak bot audio back through its mic.
12
+ // Require a much louder signal before treating incoming audio as human speech.
13
+ const SPEAKING_THRESHOLD_DB = -26;
14
+ const PREBUFFER_FRAMES = 12; // 240ms of pre-onset audio so first syllables reach STT
15
+ const LIMIT_CHECK_MS = 10_000;
16
+ const DEFAULT_MAX_CALL_MS = 30 * 60_000;
17
+ const DEFAULT_IDLE_MS = 2 * 60_000;
18
+ const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
19
+ const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
20
+
10
21
  class KazeeCallSession {
11
22
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
23
+ maxCallMs, idleMs, synthesize = synthesizePcm,
12
24
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
13
25
  this.roomId = Number(roomId);
14
26
  this.chatId = String(chatId);
@@ -20,13 +32,23 @@ class KazeeCallSession {
20
32
  this.iceServers = iceServers;
21
33
  this.onTranscript = onTranscript || (() => {});
22
34
  this.onEnded = onEnded || (() => {});
35
+ this.synthesize = synthesize;
36
+ this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
37
+ this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
23
38
  this.codec = new OpusAudioCodec();
24
- this.vad = new TurnDetector();
25
- this.stt = new SpeechStreamImpl({ onTranscript: this.onTranscript });
26
39
  this.publisher = null;
27
40
  this.publisherHandle = null;
28
41
  this.subscribers = new Map();
29
42
  this.closed = false;
43
+ this.speaking = false;
44
+ this._speakQueue = [];
45
+ this._speakActive = false;
46
+ this._speakGen = 0;
47
+ this._startedAt = 0;
48
+ this._lastHeard = 0;
49
+ this._lastSpokeAt = 0;
50
+ this._limitTimer = null;
51
+ this._ending = false;
30
52
  this.sequence = Math.floor(Math.random() * 65535);
31
53
  this.timestamp = Math.floor(Math.random() * 0xffffffff);
32
54
  this.ssrc = Math.floor(Math.random() * 0xffffffff);
@@ -40,7 +62,11 @@ class KazeeCallSession {
40
62
  request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
41
63
  });
42
64
  await this._consumePublisherFrame(joined);
43
- await this.stt.connect();
65
+ this._startedAt = Date.now();
66
+ this._lastHeard = this._startedAt;
67
+ this._lastSpokeAt = this._startedAt;
68
+ this._limitTimer = setInterval(() => { this._enforceLimits().catch(() => {}); }, LIMIT_CHECK_MS);
69
+ if (typeof this._limitTimer.unref === "function") this._limitTimer.unref();
44
70
  }
45
71
 
46
72
  async _onPublisherEvent(frame) {
@@ -52,8 +78,9 @@ class KazeeCallSession {
52
78
  const data = frame?.plugindata?.data || {};
53
79
  if (data.error) throw new Error(data.error);
54
80
  for (const publisher of data.publishers || []) {
55
- if (!this.subscribers.has(Number(publisher.id)) && !String(publisher.display || "").startsWith("open-claudia:")) {
56
- await this._subscribe(Number(publisher.id));
81
+ const feedId = Number(publisher.id);
82
+ if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
83
+ await this._subscribe(feedId, publisher.display);
57
84
  }
58
85
  }
59
86
  if (data.videoroom === "joined" && !this.publisher) await this._publish();
@@ -76,17 +103,27 @@ class KazeeCallSession {
76
103
  await pc.setRemoteDescription(answer);
77
104
  }
78
105
 
79
- async _subscribe(feedId) {
106
+ async _subscribe(feedId, display) {
80
107
  const handle = await this.janus.attach();
81
108
  const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
82
- const sub = { handle, pc };
109
+ const sub = {
110
+ handle,
111
+ pc,
112
+ display: String(display || ""),
113
+ // Opus decoders are stateful, so each feed needs its own.
114
+ codec: new OpusAudioCodec(),
115
+ vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
116
+ stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
117
+ prebuffer: [],
118
+ };
83
119
  this.subscribers.set(feedId, sub);
84
120
  pc.onTrack.subscribe((track) => {
85
- if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onIncomingRtp(packet));
121
+ if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
86
122
  });
87
123
  this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
88
124
  const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
89
125
  await this._consumeSubscriberFrame(feedId, attached);
126
+ await sub.stt.connect().catch((error) => console.error("Kazee call STT connect:", error.message));
90
127
  }
91
128
 
92
129
  async _onSubscriberEvent(feedId, frame) {
@@ -108,36 +145,126 @@ class KazeeCallSession {
108
145
  }
109
146
  }
110
147
 
111
- _onIncomingRtp(packet) {
148
+ _onFeedTranscript(feedId, text) {
149
+ const sub = this.subscribers.get(feedId);
150
+ this.onTranscript(text, {
151
+ feedId,
152
+ display: sub?.display || "",
153
+ participants: this.subscribers.size,
154
+ });
155
+ }
156
+
157
+ _onFeedRtp(feedId, packet) {
112
158
  if (this.closed || !packet?.payload?.length) return;
159
+ const sub = this.subscribers.get(feedId);
160
+ if (!sub) return;
113
161
  let pcm;
114
- try { pcm = this.codec.decode(packet.payload); } catch (_) { return; }
115
- const event = this.vad.push(pcm);
116
- if (this.vad.active || event?.type === "start") this.stt.audio(downsample48To16(pcm)).catch((e) => this.close(`stt:${e.message}`));
117
- if (event?.type === "commit") this.stt.commit().catch((e) => this.close(`stt:${e.message}`));
162
+ try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
163
+ sub.vad.thresholdDb = this.speaking ? SPEAKING_THRESHOLD_DB : BASE_THRESHOLD_DB;
164
+ const event = sub.vad.push(pcm);
165
+ const pcm16 = downsample48To16(pcm);
166
+ if (!sub.vad.active && !event) {
167
+ sub.prebuffer.push(pcm16);
168
+ if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
169
+ return;
170
+ }
171
+ if (event?.type === "start") {
172
+ this._lastHeard = Date.now();
173
+ // Barge-in: the human wins — stop talking and listen.
174
+ if (this.speaking) this.cancelSpeech();
175
+ for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
176
+ }
177
+ this._feedAudio(sub, pcm16);
178
+ if (event?.type === "commit") {
179
+ this._lastHeard = Date.now();
180
+ sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
181
+ }
182
+ }
183
+
184
+ // A transient speech-service blip drops frames but must not end a live call;
185
+ // SpeechStream reconnects on the next send.
186
+ _feedAudio(sub, pcm16) {
187
+ sub.stt.audio(pcm16).catch((error) => console.error("Kazee call STT send:", error.message));
118
188
  }
119
189
 
120
- async speak(text) {
121
- if (this.closed || !String(text || "").trim()) return;
122
- const synthesized = await synthesizePcm(String(text));
190
+ speak(text) {
191
+ if (this.closed || !String(text || "").trim()) return Promise.resolve(false);
192
+ return new Promise((resolve, reject) => {
193
+ this._speakQueue.push({ text: String(text), resolve, reject });
194
+ this._drainSpeech();
195
+ });
196
+ }
197
+
198
+ cancelSpeech() {
199
+ this._speakGen += 1;
200
+ for (const item of this._speakQueue.splice(0)) item.resolve(false);
201
+ }
202
+
203
+ _drainSpeech() {
204
+ if (this._speakActive) return;
205
+ this._speakActive = true;
206
+ (async () => {
207
+ try {
208
+ while (this._speakQueue.length && !this.closed) {
209
+ const item = this._speakQueue.shift();
210
+ try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
211
+ catch (error) { item.reject(error); }
212
+ }
213
+ } finally {
214
+ this._speakActive = false;
215
+ this.speaking = false;
216
+ }
217
+ })();
218
+ }
219
+
220
+ async _playUtterance(text, gen) {
221
+ const synthesized = await this.synthesize(text);
222
+ if (this.closed || gen !== this._speakGen) return false;
123
223
  const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
124
- for (let offset = 0; offset < pcm48.length && !this.closed; offset += WEBRTC_SAMPLES * 2) {
125
- let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
126
- if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
127
- const opus = this.codec.encode(frame);
128
- const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
129
- this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
130
- payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
131
- }), opus));
132
- this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
133
- await wait(20);
224
+ this.speaking = true;
225
+ try {
226
+ for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
227
+ if (this.closed || gen !== this._speakGen) return false;
228
+ let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
229
+ if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
230
+ const opus = this.codec.encode(frame);
231
+ const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
232
+ this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
233
+ payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
234
+ }), opus));
235
+ this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
236
+ await wait(20);
237
+ }
238
+ return true;
239
+ } finally {
240
+ this._lastSpokeAt = Date.now();
241
+ }
242
+ }
243
+
244
+ async _enforceLimits() {
245
+ if (this.closed || this._ending) return;
246
+ const now = Date.now();
247
+ const overMax = now - this._startedAt >= this.maxCallMs;
248
+ const speechIdle = !this.speaking && !this._speakActive && !this._speakQueue.length;
249
+ const idle = speechIdle && now - Math.max(this._lastHeard, this._lastSpokeAt) >= this.idleMs;
250
+ if (!overMax && !idle) return;
251
+ this._ending = true;
252
+ const heardBefore = this._lastHeard;
253
+ try { await this.speak(overMax ? MAX_DURATION_GOODBYE : IDLE_GOODBYE); } catch (_) {}
254
+ if (!overMax && this._lastHeard > heardBefore && !this.closed) {
255
+ // The caller barged in during the goodbye — stay on the line.
256
+ this._ending = false;
257
+ return;
134
258
  }
259
+ await this.close(overMax ? "max-duration" : "idle-timeout");
135
260
  }
136
261
 
137
262
  async _removeSubscriber(feedId) {
138
263
  const sub = this.subscribers.get(feedId);
139
264
  if (!sub) return;
140
265
  this.subscribers.delete(feedId);
266
+ try { sub.stt.close(); } catch (_) {}
267
+ try { sub.codec.close(); } catch (_) {}
141
268
  try { await sub.pc.close(); } catch (_) {}
142
269
  try { await this.janus.detach(sub.handle); } catch (_) {}
143
270
  }
@@ -145,7 +272,8 @@ class KazeeCallSession {
145
272
  async close(reason = "ended") {
146
273
  if (this.closed) return;
147
274
  this.closed = true;
148
- this.stt.close();
275
+ if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
276
+ this.cancelSpeech();
149
277
  for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
150
278
  try { await this.publisher?.pc?.close(); } catch (_) {}
151
279
  try { await this.janus.detach(this.publisherHandle); } catch (_) {}
package/core/runner.js CHANGED
@@ -1647,6 +1647,18 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1647
1647
  }
1648
1648
  }
1649
1649
 
1650
+ // The <voice-script> block (if the provider wrote one) is a spoken summary
1651
+ // for the voice note — strip it from the chat text; the guard above already
1652
+ // vetted the full output including the script.
1653
+ const { VOICE_REPLY_MODE } = require("./config");
1654
+ const { shouldReplyWithVoice, extractVoiceScript, speechFallback } = require("./voice-policy");
1655
+ let voiceScript = "";
1656
+ if (result.ok) {
1657
+ const parts = extractVoiceScript(finalText);
1658
+ voiceScript = parts.script;
1659
+ finalText = parts.text || parts.script || finalText;
1660
+ }
1661
+
1650
1662
  const chunks = splitMessage(finalText);
1651
1663
  for (let index = 0; index < chunks.length; index += 1) {
1652
1664
  let sent = await send(chunks[index], telegramHtmlOpts(index === 0 ? { replyTo: runContext.replyToMsgId } : {}));
@@ -1654,25 +1666,26 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1654
1666
  if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1655
1667
  }
1656
1668
 
1657
- const { VOICE_REPLY_MODE } = require("./config");
1658
- const { shouldReplyWithVoice } = require("./voice-policy");
1659
1669
  const shouldSpeak = shouldReplyWithVoice(VOICE_REPLY_MODE, runContext.lastInputWasVoice);
1660
1670
  if (result.ok && shouldSpeak) {
1661
1671
  const { currentTransport } = require("./context");
1662
1672
  if (currentTransport() === "voice") {
1663
- const sentences = splitSentences(finalText);
1673
+ const sentences = splitSentences(voiceScript || finalText);
1664
1674
  let spoke = false;
1665
1675
  for (const sentence of sentences) {
1666
1676
  const clip = await synthSentenceMp3(sentence);
1667
1677
  if (clip) { spoke = true; await sendVoice(clip); }
1668
1678
  }
1669
1679
  if (!spoke) {
1670
- const voicePath = await textToVoice(finalText);
1680
+ const voicePath = await textToVoice(voiceScript || finalText);
1671
1681
  if (voicePath) await sendVoice(voicePath);
1672
1682
  }
1673
1683
  await sendVoiceEnd();
1674
1684
  } else {
1675
- const voicePath = await textToVoice(finalText);
1685
+ // Never TTS the formatted chat text verbatim: use the provider's spoken
1686
+ // script, else a sanitized short fallback.
1687
+ const spoken = voiceScript || speechFallback(finalText);
1688
+ const voicePath = spoken ? await textToVoice(spoken) : null;
1676
1689
  if (voicePath) await sendVoice(voicePath);
1677
1690
  }
1678
1691
  }
@@ -995,10 +995,29 @@ const VOICE_REPLY_GUIDANCE = [
995
995
  "- If the answer genuinely needs code or exact detail, say so briefly and offer to send it as text rather than reading it aloud.",
996
996
  ].join("\n");
997
997
 
998
+ // Text-channel voice turns (a Telegram/Kazee voice note): the chat reply stays
999
+ // a normal full text answer, but a voice note is ALSO sent back. Asking for a
1000
+ // dedicated spoken script — stripped from the text by the runner — is what
1001
+ // keeps that voice note conversational instead of a verbatim TTS of the
1002
+ // formatted chat text.
1003
+ const VOICE_NOTE_GUIDANCE = [
1004
+ "",
1005
+ "",
1006
+ "## Voice note reply",
1007
+ "The user sent this message as a voice note, and a spoken voice note will be sent back alongside your text reply. Do both:",
1008
+ "- Write your normal text reply as usual — full detail and formatting are fine; it is delivered as text.",
1009
+ "- End the reply with a spoken script between <voice-script> and </voice-script> markers. That block is stripped from the text message and only synthesized to audio.",
1010
+ "Script rules: two to four short sentences a person would naturally SAY — summarise the essence conversationally, do not re-read the text reply. No formatting, no emoji, no lists. Never speak code, IDs, file paths, URLs, or command syntax — describe them in plain words or say the details are in the text message.",
1011
+ ].join("\n");
1012
+
998
1013
  function voiceReplyGuidance(opts = {}) {
999
1014
  try {
1000
1015
  const { currentTransport } = require("./context");
1001
- if (currentTransport() === "voice" && promptState(opts).lastInputWasVoice) return VOICE_REPLY_GUIDANCE;
1016
+ const state = promptState(opts);
1017
+ if (currentTransport() === "voice") return state.lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
1018
+ const { VOICE_REPLY_MODE } = require("./config");
1019
+ const { shouldReplyWithVoice } = require("./voice-policy");
1020
+ if (shouldReplyWithVoice(VOICE_REPLY_MODE, state.lastInputWasVoice)) return VOICE_NOTE_GUIDANCE;
1002
1021
  } catch (e) {}
1003
1022
  return "";
1004
1023
  }
@@ -4,4 +4,49 @@ function shouldReplyWithVoice(mode, lastInputWasVoice) {
4
4
  return mode === "always" || (mode === "on-voice-input" && !!lastInputWasVoice);
5
5
  }
6
6
 
7
- module.exports = { shouldReplyWithVoice };
7
+ // On voice-reply turns the provider is asked (system-prompt voice-note
8
+ // guidance) to end its reply with a spoken script between these markers. The
9
+ // block is stripped from the chat text and ONLY synthesized — chat keeps the
10
+ // full formatted answer while the voice note carries an ear-friendly summary
11
+ // instead of a verbatim TTS readout of bullets, emoji, and IDs.
12
+ const VOICE_SCRIPT_RE = /<voice-script>([\s\S]*?)(?:<\/voice-script>|$)/gi;
13
+
14
+ function extractVoiceScript(text) {
15
+ const raw = String(text || "");
16
+ const scripts = [];
17
+ const stripped = raw.replace(VOICE_SCRIPT_RE, (_match, body) => {
18
+ const clean = String(body || "").trim();
19
+ if (clean) scripts.push(clean);
20
+ return "";
21
+ });
22
+ return { text: stripped.trim(), script: scripts.join(" ").trim() };
23
+ }
24
+
25
+ // Last-resort shaping when the provider supplied no <voice-script>: strip
26
+ // everything that reads badly aloud and cap to the opening sentences, so a
27
+ // missed script degrades to a short spoken intro rather than a verbatim dump
28
+ // of formatted text into TTS.
29
+ const FALLBACK_MAX_CHARS = 400;
30
+
31
+ function speechFallback(text) {
32
+ const plain = String(text || "")
33
+ .replace(/```[\s\S]*?```/g, " ")
34
+ .replace(/<[^>\n]{0,120}>/g, " ")
35
+ .replace(/https?:\/\/\S+/gi, " ")
36
+ .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu, "")
37
+ .replace(/^\s*[-*•▸►]+\s+/gm, "")
38
+ .replace(/[*_`#|]+/g, " ")
39
+ .replace(/\s+/g, " ")
40
+ .trim();
41
+ if (plain.length <= FALLBACK_MAX_CHARS) return plain;
42
+ const sentences = plain.match(/[^.!?]+[.!?]+(?:\s+|$)/g) || [];
43
+ let out = "";
44
+ for (const sentence of sentences) {
45
+ if (out && out.length + sentence.length > FALLBACK_MAX_CHARS) break;
46
+ out += sentence;
47
+ }
48
+ out = out.trim() || plain.slice(0, FALLBACK_MAX_CHARS).trim();
49
+ return `${out} The rest is in the text message.`;
50
+ }
51
+
52
+ module.exports = { shouldReplyWithVoice, extractVoiceScript, speechFallback };
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.13",
3
+ "version": "3.1.16",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
7
7
  "open-claudia": "./bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
- "test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js",
10
+ "test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js",
13
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
14
14
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js"
15
15
  },
16
16
  "files": [
@@ -72,6 +72,12 @@
72
72
  "test-identity-prune.js",
73
73
  "test-kazee-message-id.js",
74
74
  "test-kazee-channel-health.js",
75
+ "test-kazee-call-audio.js",
76
+ "test-kazee-call-janus.js",
77
+ "test-kazee-call-webrtc.js",
78
+ "test-kazee-call-speech.js",
79
+ "test-kazee-call-manager.js",
80
+ "test-kazee-call-session.js",
75
81
  "test-prompt-budget.js",
76
82
  "test-agency-ledger.js",
77
83
  "test-agency-deliberate.js",
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("assert");
5
+ const {
6
+ WEBRTC_SAMPLES,
7
+ OpusAudioCodec,
8
+ TurnDetector,
9
+ downsample48To16,
10
+ rmsDbfs,
11
+ upsampleTo48,
12
+ } = require("./channels/kazee/call/audio");
13
+
14
+ function tone({ rate = 48000, hz = 440, samples = WEBRTC_SAMPLES, amplitude = 12000 } = {}) {
15
+ const out = Buffer.alloc(samples * 2);
16
+ const view = new Int16Array(out.buffer, out.byteOffset, samples);
17
+ for (let i = 0; i < samples; i += 1) view[i] = Math.round(Math.sin(2 * Math.PI * hz * i / rate) * amplitude);
18
+ return out;
19
+ }
20
+
21
+ const codec = new OpusAudioCodec();
22
+ const source = tone();
23
+ const packet = codec.encode(source);
24
+ const decoded = codec.decode(packet);
25
+ assert.strictEqual(decoded.length, source.length, "Opus round trip preserves the 20ms frame size");
26
+ assert(rmsDbfs(decoded) > -20, "decoded tone has useful signal energy");
27
+ codec.close();
28
+
29
+ const pcm16 = downsample48To16(source);
30
+ assert.strictEqual(pcm16.length, 320 * 2, "48k→16k produces 320 samples for a 20ms frame");
31
+ const pcm48 = upsampleTo48(pcm16, 16000);
32
+ assert.strictEqual(pcm48.length, source.length, "16k→48k restores the WebRTC frame length");
33
+
34
+ const detector = new TurnDetector({ thresholdDb: -35, onsetFrames: 2, silenceMs: 60 });
35
+ const silence = Buffer.alloc(source.length);
36
+ assert.strictEqual(detector.push(silence), null);
37
+ assert.strictEqual(detector.push(source), null);
38
+ assert.deepStrictEqual(detector.push(source), { type: "start" });
39
+ assert.strictEqual(detector.push(silence), null);
40
+ assert.strictEqual(detector.push(silence), null);
41
+ assert.deepStrictEqual(detector.push(silence), { type: "commit", reason: "silence" });
42
+
43
+ console.log("kazee call audio: ok");
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("assert");
5
+ const { WebSocketServer } = require("ws");
6
+ const { JanusClient } = require("./channels/kazee/call/janus-client");
7
+
8
+ async function main() {
9
+ const server = new WebSocketServer({ port: 0, handleProtocols: (protocols) => protocols.has("janus-protocol") ? "janus-protocol" : false });
10
+ await new Promise((resolve) => server.once("listening", resolve));
11
+ const port = server.address().port;
12
+ const seen = [];
13
+
14
+ server.on("connection", (socket) => socket.on("message", (raw) => {
15
+ const frame = JSON.parse(raw.toString());
16
+ seen.push(frame.janus);
17
+ if (frame.janus === "create") {
18
+ socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, data: { id: 42 } }));
19
+ } else if (frame.janus === "attach") {
20
+ socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, session_id: 42, data: { id: 84 } }));
21
+ } else if (frame.janus === "message") {
22
+ socket.send(JSON.stringify({ janus: "ack", transaction: frame.transaction, session_id: 42 }));
23
+ setTimeout(() => socket.send(JSON.stringify({
24
+ janus: "event",
25
+ transaction: frame.transaction,
26
+ session_id: 42,
27
+ sender: 84,
28
+ plugindata: { plugin: "janus.plugin.videoroom", data: { videoroom: "joined", room: 7 } },
29
+ })), 5);
30
+ } else if (frame.janus === "destroy") {
31
+ socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, session_id: 42 }));
32
+ }
33
+ }));
34
+
35
+ const client = new JanusClient({ url: `ws://127.0.0.1:${port}`, keepaliveMs: 60000 });
36
+ await client.connect();
37
+ assert.strictEqual(client.sessionId, 42);
38
+ const handle = await client.attach();
39
+ assert.strictEqual(handle, 84);
40
+ let handleEvent = null;
41
+ client.onHandle(handle, (event) => { handleEvent = event; });
42
+ const joined = await client.message(handle, { request: "join", ptype: "publisher", room: 7 });
43
+ assert.strictEqual(joined.plugindata.data.videoroom, "joined", "ack is ignored until the plugin event arrives");
44
+ assert.strictEqual(handleEvent?.sender, 84, "plugin event is also delivered to the handle listener");
45
+ await client.close();
46
+ await new Promise((resolve) => server.close(resolve));
47
+ assert.deepStrictEqual(seen.slice(0, 4), ["create", "attach", "message", "destroy"]);
48
+ console.log("kazee call Janus control plane: ok");
49
+ }
50
+
51
+ main().catch((error) => {
52
+ console.error(error);
53
+ process.exitCode = 1;
54
+ });
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const assert = require("assert");
4
+ const { KazeeCallManager } = require("./channels/kazee/call/manager");
5
+ class FakeSession {
6
+ static instances = [];
7
+ constructor(options) { this.options = options; this.roomId = options.roomId; FakeSession.instances.push(this); }
8
+ async start() { this.started = true; }
9
+ async speak(text) { this.spoken = text; }
10
+ async close(reason) { this.closed = reason; this.options.onEnded(reason); }
11
+ }
12
+ function fixture() {
13
+ const events = [], inbound = [];
14
+ const socket = { on() {}, emit(name, payload) { events.push({ name, payload }); } };
15
+ const adapter = { ownerUserId: "owner-1", botUserId: "bot-1", _socket: socket, _emit(name, payload) { inbound.push({ name, payload }); } };
16
+ return { events, inbound, manager: new KazeeCallManager({ adapter, SessionImpl: FakeSession }) };
17
+ }
18
+ (async () => {
19
+ let f = fixture();
20
+ await f.manager.incoming({ fromUserId: "stranger", chatId: "chat", roomId: 10, callType: "audio" });
21
+ assert.strictEqual(f.events[0].payload.reason, "not_allowed");
22
+ assert.strictEqual(FakeSession.instances.length, 0);
23
+ f = fixture();
24
+ await f.manager.incoming({ fromUserId: "owner-1", chatId: "chat", roomId: 11, callType: "video" });
25
+ assert.strictEqual(f.events[0].payload.reason, "unsupported_media");
26
+ f = fixture();
27
+ await f.manager.incoming({ fromUserId: "owner-1", fromUserName: "Owner", chatId: "chat", roomId: 12, callType: "audio" });
28
+ assert.strictEqual(f.events[0].name, "call:accept");
29
+ f.manager.session.options.onTranscript("What is on my agenda?");
30
+ assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
31
+ await f.inbound[0].payload.adapter.send("chat", "Your agenda is clear.");
32
+ assert.strictEqual(f.manager.session.spoken, "Your agenda is clear.");
33
+ await f.manager.ended({ roomId: 12 });
34
+ assert.strictEqual(f.manager.session, null);
35
+ console.log("kazee call manager: ok");
36
+ })().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("assert");
5
+ const { KazeeCallSession } = require("./channels/kazee/call/session");
6
+ const { KazeeCallManager } = require("./channels/kazee/call/manager");
7
+ const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
8
+
9
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+
11
+ class FakeJanus {
12
+ constructor() { this.handles = 0; }
13
+ async connect() {}
14
+ async attach() { this.handles += 1; return this.handles; }
15
+ onHandle() {}
16
+ async message() { return {}; }
17
+ async detach() {}
18
+ async close() {}
19
+ }
20
+
21
+ class FakePC {
22
+ constructor() { this.onTrack = { subscribe: () => {} }; }
23
+ addTrack() { return { codec: { payloadType: 111 } }; }
24
+ async createOffer() { return {}; }
25
+ async setLocalDescription() {}
26
+ async createAnswer() { return {}; }
27
+ async setRemoteDescription() {}
28
+ async close() {}
29
+ }
30
+
31
+ class FakeSpeechStream {
32
+ constructor({ onTranscript } = {}) {
33
+ this.onTranscript = onTranscript;
34
+ this.audioFrames = [];
35
+ this.commits = 0;
36
+ this.closedStream = false;
37
+ }
38
+ async connect() {}
39
+ async audio(buffer) { this.audioFrames.push(buffer); }
40
+ async commit() { this.commits += 1; }
41
+ close() { this.closedStream = true; }
42
+ }
43
+
44
+ function makeSession({ synthesize, maxCallMs, idleMs } = {}) {
45
+ const ended = [];
46
+ const transcripts = [];
47
+ const session = new KazeeCallSession({
48
+ roomId: 7, chatId: "chat", callerId: "owner", botUserId: "bot", janusUrl: "ws://test",
49
+ maxCallMs, idleMs,
50
+ synthesize: synthesize || (async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 2), sampleRate: 48000 })),
51
+ onTranscript: (text, meta) => transcripts.push({ text, meta }),
52
+ onEnded: (reason) => ended.push(reason),
53
+ JanusClientImpl: FakeJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream,
54
+ });
55
+ return { session, ended, transcripts };
56
+ }
57
+
58
+ function toneOpus(codec, amplitude) {
59
+ const pcm = Buffer.alloc(WEBRTC_SAMPLES * 2);
60
+ const view = new Int16Array(pcm.buffer, pcm.byteOffset, WEBRTC_SAMPLES);
61
+ for (let i = 0; i < view.length; i += 1) view[i] = Math.round(Math.sin(2 * Math.PI * 440 * i / 48000) * amplitude);
62
+ return codec.encode(pcm);
63
+ }
64
+
65
+ async function testSpeakSerialization() {
66
+ const writes = [];
67
+ const synthCalls = [];
68
+ let current = null;
69
+ const { session } = makeSession({
70
+ synthesize: async (text) => { synthCalls.push(text); current = text; return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 3), sampleRate: 48000 }; },
71
+ });
72
+ session.publisher = { track: { writeRtp: () => writes.push(current) }, sender: { codec: { payloadType: 111 } } };
73
+ const [first, second] = await Promise.all([session.speak("one"), session.speak("two")]);
74
+ assert.strictEqual(first, true);
75
+ assert.strictEqual(second, true);
76
+ assert.deepStrictEqual(synthCalls, ["one", "two"], "synthesis is sequential");
77
+ assert.deepStrictEqual(writes, ["one", "one", "one", "two", "two", "two"], "utterances never interleave on the RTP stream");
78
+ await session.close();
79
+ }
80
+
81
+ async function testSpeakCancellation() {
82
+ let frames = 0;
83
+ const synthCalls = [];
84
+ const { session } = makeSession({
85
+ synthesize: async (text) => { synthCalls.push(text); return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 50), sampleRate: 48000 }; },
86
+ });
87
+ session.publisher = { track: { writeRtp: () => { frames += 1; } }, sender: { codec: { payloadType: 111 } } };
88
+ const playing = session.speak("long");
89
+ const queued = session.speak("queued");
90
+ await wait(80);
91
+ session.cancelSpeech();
92
+ assert.strictEqual(await playing, false, "current utterance reports cancelled");
93
+ assert.strictEqual(await queued, false, "queued utterance cancelled");
94
+ assert(frames < 20, `playback stopped early (${frames} frames)`);
95
+ assert.deepStrictEqual(synthCalls, ["long"], "queued text never synthesized after cancel");
96
+ assert.strictEqual(session.speaking, false);
97
+ await session.close();
98
+ }
99
+
100
+ async function testEchoGateAndBargeIn() {
101
+ const { session } = makeSession({
102
+ synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
103
+ });
104
+ await session._subscribe(101, "sumeet@kazee");
105
+ const sub = session.subscribers.get(101);
106
+ const gen = new OpusAudioCodec();
107
+ session.publisher = { track: { writeRtp: () => {} }, sender: { codec: { payloadType: 111 } } };
108
+ const playing = session.speak("long answer");
109
+ await wait(30);
110
+ assert.strictEqual(session.speaking, true);
111
+ for (let i = 0; i < 6; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 1500) }); // ~-30dB: echo territory
112
+ assert.strictEqual(session.speaking, true, "echo-level audio does not interrupt TTS");
113
+ assert.strictEqual(sub.stt.audioFrames.length, 0, "echo-level audio is not streamed to STT");
114
+ for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) }); // ~-12dB: real speech
115
+ assert.strictEqual(await playing, false, "barge-in cancels TTS");
116
+ assert(sub.stt.audioFrames.length >= 3, "barge-in speech reaches STT");
117
+ gen.close();
118
+ await session.close();
119
+ }
120
+
121
+ async function testPrebufferAndCommit() {
122
+ const { session } = makeSession({});
123
+ await session._subscribe(101, "caller");
124
+ const sub = session.subscribers.get(101);
125
+ const gen = new OpusAudioCodec();
126
+ for (let i = 0; i < 5; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) }); // near-silence
127
+ assert.strictEqual(sub.stt.audioFrames.length, 0, "silence stays out of STT");
128
+ for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
129
+ assert.strictEqual(sub.stt.audioFrames.length, 8, "onset flushes the prebuffer (5 quiet + 2 onset + current)");
130
+ for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
131
+ assert.strictEqual(sub.stt.commits, 1, "turn commits after sustained silence");
132
+ gen.close();
133
+ await session.close();
134
+ }
135
+
136
+ async function testTranscriptAttribution() {
137
+ const { session, transcripts } = makeSession({});
138
+ await session._subscribe(101, "alice@kazee");
139
+ await session._subscribe(102, "bob@kazee");
140
+ session.subscribers.get(101).stt.onTranscript("hello there");
141
+ assert.deepStrictEqual(transcripts[0], {
142
+ text: "hello there",
143
+ meta: { feedId: 101, display: "alice@kazee", participants: 2 },
144
+ });
145
+ await session.close();
146
+
147
+ class FakeSession {
148
+ constructor(options) { this.options = options; this.roomId = options.roomId; }
149
+ async start() {}
150
+ async speak() {}
151
+ async close(reason) { this.options.onEnded(reason); }
152
+ }
153
+ const inbound = [];
154
+ const adapter = { ownerUserId: "owner-1", botUserId: "bot-1", _socket: { on() {}, emit() {} }, _emit: (name, payload) => inbound.push(payload) };
155
+ const manager = new KazeeCallManager({ adapter, SessionImpl: FakeSession });
156
+ await manager.incoming({ fromUserId: "owner-1", chatId: "c", roomId: 5, callType: "audio" });
157
+ manager.session.options.onTranscript("hi", { feedId: 1, display: "alice@kazee", participants: 2 });
158
+ assert.strictEqual(inbound[0].text, "alice@kazee: hi", "group transcripts are speaker-prefixed");
159
+ assert.strictEqual(inbound[0].raw.speaker, "alice@kazee");
160
+ manager.session.options.onTranscript("solo", { feedId: 1, display: "alice@kazee", participants: 1 });
161
+ assert.strictEqual(inbound[1].text, "solo", "1:1 transcripts stay unprefixed");
162
+ await manager.stop();
163
+ }
164
+
165
+ async function testIdleTimeout() {
166
+ const goodbyes = [];
167
+ const { session, ended } = makeSession({
168
+ idleMs: 50, maxCallMs: 60_000,
169
+ synthesize: async (text) => { goodbyes.push(text); return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2), sampleRate: 48000 }; },
170
+ });
171
+ session._startedAt = Date.now();
172
+ session._lastHeard = Date.now() - 1000;
173
+ session._lastSpokeAt = Date.now() - 1000;
174
+ await session._enforceLimits();
175
+ assert.strictEqual(ended[0], "idle-timeout");
176
+ assert.strictEqual(goodbyes.length, 1, "idle hangup says goodbye first");
177
+ assert(goodbyes[0].toLowerCase().includes("hang up"));
178
+ }
179
+
180
+ async function testIdleBargeInKeepsCall() {
181
+ let sess = null;
182
+ const { session, ended } = makeSession({
183
+ idleMs: 50, maxCallMs: 60_000,
184
+ synthesize: async () => { sess._lastHeard = Date.now(); return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2), sampleRate: 48000 }; },
185
+ });
186
+ sess = session;
187
+ session._startedAt = Date.now();
188
+ session._lastHeard = Date.now() - 1000;
189
+ session._lastSpokeAt = Date.now() - 1000;
190
+ await session._enforceLimits();
191
+ assert.strictEqual(ended.length, 0, "call stays up when the caller speaks during the goodbye");
192
+ assert.strictEqual(session._ending, false);
193
+ await session.close();
194
+ }
195
+
196
+ async function testMaxDuration() {
197
+ const { session, ended } = makeSession({ idleMs: 60_000, maxCallMs: 50 });
198
+ session._startedAt = Date.now() - 1000;
199
+ session._lastHeard = Date.now();
200
+ session._lastSpokeAt = Date.now();
201
+ await session._enforceLimits();
202
+ assert.strictEqual(ended[0], "max-duration", "cap closes the call even mid-conversation");
203
+ }
204
+
205
+ async function testCloseResolvesSpeech() {
206
+ const { session } = makeSession({
207
+ synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
208
+ });
209
+ await session._subscribe(101, "x");
210
+ const sub = session.subscribers.get(101);
211
+ const playing = session.speak("very long");
212
+ await wait(40);
213
+ await session.close("ended");
214
+ assert.strictEqual(await playing, false, "in-flight speak resolves on close");
215
+ assert.strictEqual(sub.stt.closedStream, true, "per-feed STT closed with the call");
216
+ assert.strictEqual(session.subscribers.size, 0);
217
+ assert.strictEqual(await session.speak("after"), false, "speak after close is a no-op");
218
+ }
219
+
220
+ (async () => {
221
+ await testSpeakSerialization();
222
+ await testSpeakCancellation();
223
+ await testEchoGateAndBargeIn();
224
+ await testPrebufferAndCommit();
225
+ await testTranscriptAttribution();
226
+ await testIdleTimeout();
227
+ await testIdleBargeInKeepsCall();
228
+ await testMaxDuration();
229
+ await testCloseResolvesSpeech();
230
+ console.log("kazee call session: ok");
231
+ })().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const assert = require("assert");
4
+ const { EventEmitter } = require("events");
5
+ const { SpeechStream, synthesizePcm } = require("./channels/kazee/call/speech-client");
6
+ class FakeWebSocket extends EventEmitter {
7
+ static OPEN = 1;
8
+ constructor(url, options) { super(); this.url = url; this.options = options; this.readyState = 1; this.sent = []; queueMicrotask(() => this.emit("open")); }
9
+ send(value) { this.sent.push(value); }
10
+ close() { this.readyState = 3; this.emit("close"); }
11
+ }
12
+ (async () => {
13
+ let transcript;
14
+ const stream = new SpeechStream({ baseUrl: "https://speech.example", token: "secret", WebSocketImpl: FakeWebSocket, onTranscript: (t) => { transcript = t; } });
15
+ await stream.connect();
16
+ assert.strictEqual(stream.socket.url, "wss://speech.example/v1/audio/transcriptions/stream");
17
+ assert.deepStrictEqual(JSON.parse(stream.socket.sent[0]), { sample_rate: 16000, language: "en" });
18
+ await stream.audio(Buffer.from([1, 2]));
19
+ await stream.commit();
20
+ assert(Buffer.isBuffer(stream.socket.sent[1]));
21
+ stream.socket.emit("message", Buffer.from(JSON.stringify({ type: "final", text: "hello" })));
22
+ assert.strictEqual(transcript, "hello");
23
+ let request;
24
+ const pcm = await synthesizePcm("Hello", { baseUrl: "https://speech.example", token: "secret", fetchImpl: async (url, options) => {
25
+ request = { url, options }; return {
26
+ ok: true,
27
+ headers: { get: () => "audio/pcm;rate=24000;encoding=signed-integer;bits=16" },
28
+ arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,
29
+ };
30
+ } });
31
+ assert.strictEqual(JSON.parse(request.options.body).response_format, "pcm");
32
+ assert.strictEqual(pcm.sampleRate, 24000);
33
+ assert.deepStrictEqual([...pcm.pcm], [1, 2, 3]);
34
+ stream.close();
35
+ console.log("kazee call speech: ok");
36
+ })().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("assert");
5
+ const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
6
+ const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
7
+
8
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+
10
+ async function main() {
11
+ const publisher = new RTCPeerConnection();
12
+ const subscriber = new RTCPeerConnection();
13
+ const outgoing = new MediaStreamTrack({ kind: "audio" });
14
+ const sender = publisher.addTrack(outgoing);
15
+ let resolvePacket;
16
+ const received = new Promise((resolve) => { resolvePacket = resolve; });
17
+ subscriber.onTrack.subscribe((track) => track.onReceiveRtp.subscribe(resolvePacket));
18
+
19
+ const offer = await publisher.createOffer();
20
+ await publisher.setLocalDescription(offer);
21
+ await subscriber.setRemoteDescription(publisher.localDescription);
22
+ const answer = await subscriber.createAnswer();
23
+ await subscriber.setLocalDescription(answer);
24
+ await publisher.setRemoteDescription(subscriber.localDescription);
25
+
26
+ for (let attempt = 0; attempt < 100 && publisher.connectionState !== "connected"; attempt += 1) await wait(20);
27
+ assert.strictEqual(publisher.connectionState, "connected", "local WebRTC peers connect");
28
+
29
+ const pcm = Buffer.alloc(WEBRTC_SAMPLES * 2);
30
+ const samples = new Int16Array(pcm.buffer, pcm.byteOffset, WEBRTC_SAMPLES);
31
+ for (let i = 0; i < samples.length; i += 1) samples[i] = Math.round(Math.sin(2 * Math.PI * 440 * i / 48000) * 12000);
32
+ const codec = new OpusAudioCodec();
33
+ const opus = codec.encode(pcm);
34
+ outgoing.writeRtp(new RtpPacket(new RtpHeader({
35
+ payloadType: sender.codec?.payloadType || 111,
36
+ sequenceNumber: 1,
37
+ timestamp: WEBRTC_SAMPLES,
38
+ ssrc: 0x0c1a0d10,
39
+ }), opus));
40
+
41
+ const packet = await Promise.race([received, wait(3000).then(() => null)]);
42
+ assert(packet, "encrypted WebRTC path delivers an RTP packet");
43
+ assert.deepStrictEqual(packet.payload, opus, "Opus payload survives ICE/DTLS/SRTP transport");
44
+ assert.strictEqual(codec.decode(packet.payload).length, pcm.length, "received Opus decodes to a 20ms PCM frame");
45
+
46
+ codec.close();
47
+ await publisher.close();
48
+ await subscriber.close();
49
+ console.log("kazee call WebRTC loopback: ok");
50
+ }
51
+
52
+ main().catch((error) => {
53
+ console.error(error);
54
+ process.exitCode = 1;
55
+ });
@@ -95,7 +95,10 @@ async function providerProbe(kind) {
95
95
  const service = enforcer.createEnforcerService({
96
96
  spawnUtilityAgent: utility.spawnUtilityAgent,
97
97
  audit,
98
- timeoutMs: 2000,
98
+ // Generous: children answer in ms, so the green path never waits this out.
99
+ // The deadline exists only as the fail-closed backstop, and 2000ms was
100
+ // missed by child boot on a loaded CI runner (pipeline 10445 flake).
101
+ timeoutMs: 8000,
99
102
  });
100
103
  const speaker = { name: "External Fixture", relationship: "external", mandate: "May discuss invoice status only." };
101
104
  const secret = ["sk", "proj", "guard", "fixture", "private"].join("-");
@@ -106,9 +109,10 @@ async function providerProbe(kind) {
106
109
  const timeoutService = enforcer.createEnforcerService({
107
110
  spawnUtilityAgent: utility.spawnUtilityAgent,
108
111
  audit,
109
- // 2500ms so the hung child boots on a loaded CI runner (writing capture #5
110
- // and arming its SIGTERM recorder) before the deadline fires.
111
- timeoutMs: 2500,
112
+ // Bounded (this deadline MUST fire — the child hangs by design), but with
113
+ // enough headroom for the hung child to boot on a loaded CI runner (write
114
+ // capture #5, arm its SIGTERM recorder) before the deadline kills it.
115
+ timeoutMs: 5000,
112
116
  });
113
117
  const timedOut = await timeoutService.vetReply(speaker, "timeout verdict fixture");
114
118
 
@@ -207,7 +207,22 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
207
207
  // 3.1.13 approved by Sumeet 2026-07-22 ("work through till all your plan is
208
208
  // completed"): owner-only incoming Kazee audio calls bridge Janus VideoRoom
209
209
  // RTP to hosted streaming STT, the normal agent turn, and streamed Pocket TTS.
210
- assert.strictEqual(pkg.version, "3.1.13", "release version must remain unchanged without explicit approval");
210
+ // 3.1.14 approved by Sumeet 2026-07-22 (calls deep-dive: "you're welcome to go
211
+ // ahead and work through all the way until everything's deployed"): call bridge
212
+ // production hardening — serialized TTS with barge-in cancellation, echo gate,
213
+ // per-feed STT with group speaker attribution, duration/idle limits with a
214
+ // spoken goodbye, and the call tests wired into the main npm test chain.
215
+ // 3.1.15 approved by Sumeet 2026-07-22 ("if replying in voice it needs to be
216
+ // summaries etc but not just conversions directly"): voice-note replies now
217
+ // speak a provider-written conversational <voice-script> summary (stripped from
218
+ // the chat text) instead of a verbatim TTS of the formatted answer, with a
219
+ // sanitizing speechFallback when the script is missing.
220
+ // 3.1.16 approved by Sumeet 2026-07-22 (same calls-directive authority; re-cut
221
+ // of the never-published 3.1.15 after two loaded-runner CI flakes): test-only
222
+ // deflake — heal-guard test stubs its live api.telegram.org probe and isolates
223
+ // CONFIG_DIR; enforcer probe deadlines raised to CI-safe values (2000→8000ms
224
+ // backstop, 2500→5000ms deliberate-timeout boot headroom). No runtime changes.
225
+ assert.strictEqual(pkg.version, "3.1.16", "release version must remain unchanged without explicit approval");
211
226
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
212
227
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
213
228
  }