@inetafrica/open-claudia 3.4.13 → 3.4.15

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.15
4
+
5
+ - Preserve complete Kazee speech turns with protocol-2 segment matching, bounded pending finals, and cancellation when speech resumes during an endpoint decision. Keep compatibility with older speech servers, including a delayed protocol-ready message.
6
+ - Stop obsolete speech and remove superseded queued call turns on interruption or hangup. Use a fresh provider conversation with bounded spoken history while preserving identity, policy and recall. In-progress tools continue under their existing lifecycle.
7
+ - Add `open-claudia call-say` for explicit complete answer sentences before the provider finishes. Each request is bound to the active call and run, redacted, and checked by the outbound reply guard. Retries are idempotent; final delivery removes the exact prefix already spoken. Provider previews and reasoning remain unspoken; this does not add token streaming to the provider transport.
8
+ - Replace repeated thinking sounds with optional delayed fixed cues from the separate speech controller. Endpoint and cue models require the corresponding speech-service deployment; model quality, live latency and Android acceptance remain separate verification gates.
9
+ - Isolate permission-test fixtures from inherited test-launcher approval without changing production permission gates.
10
+
11
+ ## 3.4.14
12
+
13
+ - Subscribe to managed call audio only when gateway-authenticated publisher metadata identifies a User. Exclude bots, the current bot's other feeds, and missing or malformed classifications before creating subscriber connections or speech streams.
14
+ - Preserve metadata through the initial room response and live publisher events. Managed calls use that authenticated classification; legacy calls retain their existing display-prefix exclusion because client-supplied metadata is untrusted.
15
+ - Exercise the actual session subscription and offer/answer path for allowed users and rejected bot, self and unknown feeds.
16
+
3
17
  ## 3.4.13
4
18
 
5
19
  - **Codex is now measured the way Claude is.** After each Codex turn the bot reads Codex's own per-request token log (`$CODEX_HOME/sessions/…/rollout-*-<thread>.jsonl`) and uses the last request's size as the live context, instead of the turn's input summed over every tool call. That sum (33M on one turn) tripped auto-compact after almost every substantial Codex turn — 8 needless compactions on 2026-09-05, each a summary run plus a cold restart — while Codex's real window occupancy never exceeded 245k of its 258k window. Codex sessions now compact at 80% of the window Codex reports.
package/bin/cli.js CHANGED
@@ -277,6 +277,33 @@ switch (command) {
277
277
  break;
278
278
  }
279
279
 
280
+ case "call-say": {
281
+ if (args[1] === "--help" || args[1] === "-h") {
282
+ console.log("Usage: open-claudia call-say [--id <request-id>] <text>\nOr: open-claudia call-say [--id <request-id>] --stdin\nSpeak one checked 1–450 character chunk into the current call turn. Wait for completion; reuse an ID only when retrying identical text.");
283
+ break;
284
+ }
285
+ const parts = args.slice(1);
286
+ let requestId = require("crypto").randomUUID();
287
+ if (parts[0] === "--id") { parts.shift(); requestId = parts.shift(); }
288
+ const text = parts.length === 1 && parts[0] === "--stdin"
289
+ ? require("fs").readFileSync(0, "utf8").trim() : parts.join(" ").trim();
290
+ if (!text || text.length > 450 || !/^[A-Za-z0-9_-]{1,80}$/.test(requestId || "")) {
291
+ console.error("Provide a valid request ID and 1–450 characters of spoken text.");
292
+ process.exit(2);
293
+ }
294
+ (async () => {
295
+ try {
296
+ const result = await require("./loopback-client").postJson("call-say", { text, requestId });
297
+ if (result.ok !== true || result.spoken !== true) throw new Error("Speech was not completed");
298
+ console.log(result.duplicate ? "Already spoken." : "Spoken.");
299
+ } catch (error) {
300
+ console.error(error.message);
301
+ process.exitCode = 1;
302
+ }
303
+ })();
304
+ break;
305
+ }
306
+
280
307
  case "say": {
281
308
  // Agents probe with --help before speaking; don't synthesize the flag.
282
309
  if (args[1] === "--help" || args[1] === "-h") {
@@ -563,6 +590,7 @@ Send tools (only work inside an active bot-spawned task):
563
590
  open-claudia send-photo <path> [caption]
564
591
  open-claudia send-voice <path>
565
592
  open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
593
+ open-claudia call-say "<text>" Speak a checked sentence into the active call turn
566
594
  open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
567
595
  narration; ext picks format; --voice <id> optional)
568
596
  open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
@@ -75,6 +75,7 @@ function postJson(kind, body) {
75
75
  const sameProject = !ctx.project || !requestedProject || requestedProject === ctx.project;
76
76
  const payload = JSON.stringify({
77
77
  ...body,
78
+ ...(kind === "call-say" ? { callSpeechToken: process.env.OC_CALL_SPEECH_TOKEN || "" } : {}),
78
79
  channelId: ctx.channelId,
79
80
  adapter: ctx.adapterId,
80
81
  userId: ctx.userId,
@@ -86,13 +86,21 @@ class KazeeCallManager {
86
86
  }
87
87
 
88
88
  _sessionEnded(session, reason) {
89
+ // Several terminal paths clear this.session before close() calls back.
90
+ // Invalidate the closed call's work even when it no longer owns the slot.
91
+ session?._voiceTurn?.controller.abort("call-ended");
89
92
  if (this.session !== session) return;
90
- this.session = null;
93
+ this._detachSession(session);
91
94
  if (this.adapter._socket?.connected) this.adapter._socket.emit("call:end", {
92
95
  ...session.callIdentity, chatId: session.chatId, toUserId: session.callerId, reason,
93
96
  });
94
97
  }
95
98
 
99
+ _detachSession(session) {
100
+ session?._voiceTurn?.controller.abort("call-ended");
101
+ if (this.session === session) this.session = null;
102
+ }
103
+
96
104
  _manageLease(session, access) {
97
105
  if (!access) return;
98
106
  const socket = this.adapter._socket;
@@ -103,7 +111,7 @@ class KazeeCallManager {
103
111
  if (this.session !== session || session.closed) return;
104
112
  console.warn(`Kazee managed call lease ${reason}`);
105
113
  socket.emit("call:end", { ...session.callIdentity, deviceId: this.deviceId, chatId: session.chatId, toUserId: session.callerId, reason: "network-timeout" });
106
- this.session = null;
114
+ this._detachSession(session);
107
115
  session.close(`lease-${reason}`).catch(() => console.error("Kazee call lease cleanup failed"));
108
116
  }, access.leaseExpiresAt, randomUUID);
109
117
  }
@@ -141,7 +149,8 @@ class KazeeCallManager {
141
149
  const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
142
150
  session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
143
151
  ...this._sessionOptions(config, access, boundIdentity, pending.controller.signal), greeting: INCOMING_GREETING,
144
- onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta),
152
+ onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta, session),
153
+ onSpeechStart: () => this._speechStart(session),
145
154
  onMediaReady: () => this._mediaReady(session),
146
155
  onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._sessionEnded(session, reason); },
147
156
  });
@@ -156,7 +165,7 @@ class KazeeCallManager {
156
165
  if (session._mediaReady) session._leaseHeartbeat?.activate();
157
166
  } catch (_) {
158
167
  const cancelled = pending.controller.signal.aborted;
159
- if (this.session === session) this.session = null;
168
+ this._detachSession(session);
160
169
  if (session) await session.close("connect-failed").catch(() => {});
161
170
  if (!cancelled) reject("connection_failed");
162
171
  } finally { if (this._pending === pending) this._pending = null; }
@@ -187,7 +196,8 @@ class KazeeCallManager {
187
196
  const remote = { ...identity, chatId: chat, fromUserId: target, fromUserName: "" };
188
197
  session = new this.SessionImpl({ roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
189
198
  ...this._sessionOptions(config, access, identity, pending.controller.signal), asCaller: true, greeting: OUTGOING_GREETING,
190
- onTranscript: (text, meta) => this._transcript(text, remote, meta),
199
+ onTranscript: (text, meta) => this._transcript(text, remote, meta, session),
200
+ onSpeechStart: () => this._speechStart(session),
191
201
  onMediaReady: () => this._mediaReady(session),
192
202
  onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._clearRingTimer(); this._sessionEnded(session, reason); },
193
203
  });
@@ -202,7 +212,7 @@ class KazeeCallManager {
202
212
  this._ringTimer.unref?.();
203
213
  return { ...identity, chatId: chat, toUserId: target };
204
214
  } catch (error) {
205
- if (this.session === session) this.session = null;
215
+ this._detachSession(session);
206
216
  if (session) await session.close("connect-failed").catch(() => {});
207
217
  throw error;
208
218
  } finally { if (this._pending === pending) this._pending = null; }
@@ -223,7 +233,7 @@ class KazeeCallManager {
223
233
  // Caller hanging up while ringing: chat-central finalizes the CallLog as
224
234
  // missed and dismisses the callee's ringing UI/push.
225
235
  this.adapter._socket?.emit("call:end", { ...session.callIdentity, toUserId, chatId });
226
- this.session = null;
236
+ this._detachSession(session);
227
237
  await session.close("no-answer").catch(() => {});
228
238
  }
229
239
 
@@ -231,23 +241,65 @@ class KazeeCallManager {
231
241
  if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
232
242
  }
233
243
 
234
- _transcript(text, incoming, meta = {}) {
244
+ _speechStart(session) {
245
+ if (session?._voiceTurn) session._voiceTurn.muted = true;
246
+ // Sound alone is not a new request. Cancel queued work only once a new
247
+ // complete transcript exists; a cough must not discard an accepted ask.
248
+ }
249
+
250
+ _rememberSpeech(session, role, text) {
251
+ const history = session._voiceHistory ||= [];
252
+ if (role === "assistant" && history.at(-1)?.role === "assistant") {
253
+ history.at(-1).text = `${history.at(-1).text} ${text}`.slice(-6000);
254
+ } else history.push({ role, text: String(text).slice(-6000) });
255
+ while (history.length > 8 || history.reduce((size, entry) => size + entry.text.length, 0) > 12000) history.shift();
256
+ }
257
+
258
+ _transcript(text, incoming, meta = {}, sourceSession = this.session) {
235
259
  const session = this.session;
236
- if (!session || !text) return;
260
+ if (!session || session.closed || sourceSession !== session || !text) return;
237
261
  // In a group room, tell the provider who is talking; 1:1 stays unprefixed.
238
262
  const speaker = String(meta.display || "").trim();
239
263
  const attributed = (meta.participants || 0) > 1 && speaker ? `${speaker}: ${text}` : text;
264
+ session._voiceTurn?.controller.abort("superseded");
265
+ const turn = { controller: new AbortController(), muted: false };
266
+ session._voiceTurn = turn;
240
267
  const callAdapter = Object.create(this.adapter);
268
+ callAdapter.callHistory = (session._voiceHistory || []).map(entry => ({ ...entry }));
269
+ callAdapter.callTurn = { signal: turn.controller.signal };
270
+ this._rememberSpeech(session, "user", attributed);
241
271
  // Provider narration/previews are useful in chat but must never be spoken
242
- // into a call. Only the settled, guarded final answer reaches send().
272
+ // into a call. send() receives settled answers; explicit sentence speech
273
+ // enters separately through the call-turn lease and outbound reply guard.
243
274
  callAdapter.suppressStreamPreview = true;
244
275
  // Recall/tooltrace banners route through the same send() as the answer —
245
276
  // without this flag the TTS reads memory-debug lines to the caller.
246
277
  callAdapter.suppressChatVisibility = true;
247
278
  // Prompt builder switches to phone-call brevity when it sees this.
248
279
  callAdapter.liveCall = true;
280
+ const spokenSentences = [];
281
+ callAdapter.isCallTurnActive = () => !turn.muted && !turn.controller.signal.aborted &&
282
+ !session.closed && this.session === session && session._voiceTurn === turn;
283
+ callAdapter.cancelCallSpeech = () => {
284
+ if (this.session === session && session._voiceTurn === turn) session.cancelSpeech();
285
+ };
286
+ const speak = async response => {
287
+ if (!callAdapter.isCallTurnActive()) return false;
288
+ const spoken = await session.speak(response);
289
+ if (spoken !== false && callAdapter.isCallTurnActive()) {
290
+ this._rememberSpeech(session, "assistant", response);
291
+ return true;
292
+ }
293
+ return false;
294
+ };
295
+ callAdapter.speakSentence = async response => {
296
+ const spoken = await speak(response);
297
+ if (spoken) spokenSentences.push(response);
298
+ return spoken;
299
+ };
249
300
  callAdapter.send = async (_channelId, response) => {
250
- await session.speak(response);
301
+ const remainder = require("../../../core/call-speech").unspokenSuffix(response, spokenSentences);
302
+ if (remainder) await speak(remainder);
251
303
  return true;
252
304
  };
253
305
  callAdapter.edit = async () => true;
@@ -275,7 +327,7 @@ class KazeeCallManager {
275
327
  if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
276
328
  // Terminal server state is authoritative; ended attempts are never revived.
277
329
  const session = this.session;
278
- this.session = null;
330
+ this._detachSession(session);
279
331
  await session.close("remote-ended");
280
332
  }
281
333
 
@@ -283,7 +335,7 @@ class KazeeCallManager {
283
335
  if (!this.session || !this._matches(payload)) return;
284
336
  if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
285
337
  const session = this.session;
286
- this.session = null;
338
+ this._detachSession(session);
287
339
  await session.close("remote-rejected");
288
340
  }
289
341
 
@@ -292,7 +344,7 @@ class KazeeCallManager {
292
344
  this._clearRingTimer();
293
345
  if (!this.session) return;
294
346
  const session = this.session;
295
- this.session = null;
347
+ this._detachSession(session);
296
348
  await session.close("adapter-stopped");
297
349
  }
298
350
  }