@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 +14 -0
- package/bin/cli.js +28 -0
- package/bin/loopback-client.js +1 -0
- package/channels/kazee/call/manager.js +66 -14
- package/channels/kazee/call/session.js +171 -84
- package/channels/kazee/call/speech-client.js +96 -19
- package/channels/kazee/call/turn-gate.js +62 -0
- package/core/call-speech.js +111 -0
- package/core/loopback.js +10 -1
- package/core/providers/env.js +1 -0
- package/core/run-context.js +13 -6
- package/core/runner.js +58 -6
- package/core/system-prompt.js +9 -1
- package/package.json +4 -3
- package/test-call-speech.js +237 -0
- package/test-kazee-call-manager.js +57 -2
- package/test-kazee-call-session.js +217 -9
- package/test-kazee-call-speech.js +38 -2
- package/test-provider-env-isolation.js +5 -0
- package/test-provider-gateway.js +15 -1
- package/test-provider-language.js +3 -1
- package/test-provider-prompt-parity.js +16 -1
- package/test-provider-runner.js +34 -0
- package/test-tool-manifest.js +25 -12
- package/test-tools.js +11 -7
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
|
package/bin/loopback-client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
347
|
+
this._detachSession(session);
|
|
296
348
|
await session.close("adapter-stopped");
|
|
297
349
|
}
|
|
298
350
|
}
|