@inetafrica/open-claudia 3.1.13 → 3.1.14

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.1.14 — Call bridge production hardening: serialized speech, barge-in, group attribution, limits
4
+
5
+ - **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.
6
+ - **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.
7
+ - **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).
8
+ - **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.
9
+ - **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.
10
+ - 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.
11
+
3
12
  ## v3.1.13 — Owner-only Kazee calls reach Open Claudia
4
13
 
5
14
  - 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/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.13",
3
+ "version": "3.1.14",
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
+ });
@@ -207,7 +207,12 @@ 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
+ assert.strictEqual(pkg.version, "3.1.14", "release version must remain unchanged without explicit approval");
211
216
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
212
217
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
213
218
  }