@inetafrica/open-claudia 3.1.14 → 3.1.17

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.1.17 — Call bridge joins through the caller's room-setup race (fixes "Call declined")
4
+
5
+ - Answering a Kazee web call could fail deterministically fast: the caller creates the Janus videoroom during its own dial-time setup, and the bot answers quicker than any human — its `join` hit Janus before the room existed, got 426 "No such room", and the whole call was rejected as `connection_failed` (surfacing as "Call declined" in the web UI).
6
+ - `session.start()` now polls the publisher join through transient 426 (10 attempts × 500ms), mirroring the web receiver's own retry loop in `CallContext.startVideoRoom`. Persistent 426 still fails the call.
7
+ - Janus error frames now carry `error.code` alongside the message for future discrimination.
8
+ - New assertions: join retries through transient no-such-room and still gives up when the room never appears.
9
+
10
+ ## v3.1.16 — CI-deflake: no live network in unit tests, CI-safe enforcer deadlines
11
+
12
+ - 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).
13
+ - `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.
14
+ - `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.
15
+
16
+ ## v3.1.15 — Voice-note replies speak a conversational summary, not the chat text
17
+
18
+ - 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.
19
+ - 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.
20
+ - The Kazee call bridge keeps its existing in-call guidance and streams `voiceScript || finalText`, so stray markers are never spoken there either.
21
+ - New extraction/fallback/wiring assertions in `test-media-speech.js`.
22
+
3
23
  ## v3.1.14 — Call bridge production hardening: serialized speech, barge-in, group attribution, limits
4
24
 
5
25
  - **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.
@@ -15,6 +15,8 @@ const PREBUFFER_FRAMES = 12; // 240ms of pre-onset audio so first syllables reac
15
15
  const LIMIT_CHECK_MS = 10_000;
16
16
  const DEFAULT_MAX_CALL_MS = 30 * 60_000;
17
17
  const DEFAULT_IDLE_MS = 2 * 60_000;
18
+ const DEFAULT_JOIN_RETRIES = 10;
19
+ const DEFAULT_JOIN_RETRY_DELAY_MS = 500;
18
20
  const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
19
21
  const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
20
22
 
@@ -35,6 +37,8 @@ class KazeeCallSession {
35
37
  this.synthesize = synthesize;
36
38
  this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
37
39
  this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
40
+ this.joinRetries = DEFAULT_JOIN_RETRIES;
41
+ this.joinRetryDelayMs = DEFAULT_JOIN_RETRY_DELAY_MS;
38
42
  this.codec = new OpusAudioCodec();
39
43
  this.publisher = null;
40
44
  this.publisherHandle = null;
@@ -58,9 +62,20 @@ class KazeeCallSession {
58
62
  await this.janus.connect();
59
63
  this.publisherHandle = await this.janus.attach();
60
64
  this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
61
- const joined = await this.janus.message(this.publisherHandle, {
62
- request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
63
- });
65
+ // The caller creates the Janus room during its own call setup, which can
66
+ // land a beat after the invite reaches us — and a bot answers faster than
67
+ // any human. Poll join through transient 426 "No such room" like the web
68
+ // receiver's retry loop in CallContext.startVideoRoom.
69
+ let joined;
70
+ for (let attempt = 0; ; attempt += 1) {
71
+ joined = await this.janus.message(this.publisherHandle, {
72
+ request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
73
+ });
74
+ const data = joined?.plugindata?.data || {};
75
+ const noRoom = Number(data.error_code) === 426 || /no such room/i.test(String(data.error || ""));
76
+ if (!noRoom || attempt >= this.joinRetries || this.closed) break;
77
+ await wait(this.joinRetryDelayMs);
78
+ }
64
79
  await this._consumePublisherFrame(joined);
65
80
  this._startedAt = Date.now();
66
81
  this._lastHeard = this._startedAt;
@@ -76,7 +91,11 @@ class KazeeCallSession {
76
91
 
77
92
  async _consumePublisherFrame(frame) {
78
93
  const data = frame?.plugindata?.data || {};
79
- if (data.error) throw new Error(data.error);
94
+ if (data.error) {
95
+ const error = new Error(data.error);
96
+ if (data.error_code != null) error.code = Number(data.error_code);
97
+ throw error;
98
+ }
80
99
  for (const publisher of data.publishers || []) {
81
100
  const feedId = Number(publisher.id);
82
101
  if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
@@ -135,7 +154,11 @@ class KazeeCallSession {
135
154
  const sub = this.subscribers.get(feedId);
136
155
  if (!sub) return;
137
156
  const data = frame?.plugindata?.data || {};
138
- if (data.error) throw new Error(data.error);
157
+ if (data.error) {
158
+ const error = new Error(data.error);
159
+ if (data.error_code != null) error.code = Number(data.error_code);
160
+ throw error;
161
+ }
139
162
  if (frame.jsep && !sub.started) {
140
163
  sub.started = true;
141
164
  await sub.pc.setRemoteDescription(frame.jsep);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.14",
3
+ "version": "3.1.17",
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": {
@@ -202,6 +202,32 @@ async function testMaxDuration() {
202
202
  assert.strictEqual(ended[0], "max-duration", "cap closes the call even mid-conversation");
203
203
  }
204
204
 
205
+ async function testJoinRetriesNoSuchRoom() {
206
+ const { session } = makeSession({});
207
+ session.joinRetryDelayMs = 1;
208
+ let joins = 0;
209
+ session.janus.message = async (_handle, body) => {
210
+ if (body?.request !== "join") return {};
211
+ joins += 1;
212
+ if (joins < 3) return { plugindata: { data: { videoroom: "event", error_code: 426, error: "No such room (7)" } } };
213
+ return {};
214
+ };
215
+ await session.start();
216
+ assert.strictEqual(joins, 3, "join retried through transient no-such-room");
217
+ await session.close();
218
+ }
219
+
220
+ async function testJoinGivesUpAfterRetries() {
221
+ const { session } = makeSession({});
222
+ session.joinRetryDelayMs = 1;
223
+ session.joinRetries = 2;
224
+ session.janus.message = async (_handle, body) => body?.request === "join"
225
+ ? { plugindata: { data: { videoroom: "event", error_code: 426, error: "No such room (7)" } } }
226
+ : {};
227
+ await assert.rejects(() => session.start(), /No such room/, "persistent 426 still fails the call");
228
+ await session.close();
229
+ }
230
+
205
231
  async function testCloseResolvesSpeech() {
206
232
  const { session } = makeSession({
207
233
  synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
@@ -226,6 +252,8 @@ async function testCloseResolvesSpeech() {
226
252
  await testIdleTimeout();
227
253
  await testIdleBargeInKeepsCall();
228
254
  await testMaxDuration();
255
+ await testJoinRetriesNoSuchRoom();
256
+ await testJoinGivesUpAfterRetries();
229
257
  await testCloseResolvesSpeech();
230
258
  console.log("kazee call session: ok");
231
259
  })().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -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
 
@@ -212,7 +212,22 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
212
212
  // production hardening — serialized TTS with barge-in cancellation, echo gate,
213
213
  // per-feed STT with group speaker attribution, duration/idle limits with a
214
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");
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
+ // 3.1.17 approved by Sumeet 2026-07-23 (same calls-directive authority; his web
226
+ // call to an upgraded bot showed "Call declined"): the call bridge now retries
227
+ // the Janus publisher join through transient 426 "No such room" — the caller
228
+ // creates the room at dial time and the bot answers faster than any human, so
229
+ // the single naked join lost the race deterministically.
230
+ assert.strictEqual(pkg.version, "3.1.17", "release version must remain unchanged without explicit approval");
216
231
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
217
232
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
218
233
  }