@inetafrica/open-claudia 3.1.16 → 3.1.20
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 +7 -0
- package/channels/kazee/call/janus-client.js +5 -2
- package/channels/kazee/call/manager.js +13 -3
- package/channels/kazee/call/session.js +136 -8
- package/core/handlers.js +4 -2
- package/core/recall/ask.js +48 -0
- package/core/recall/index.js +5 -1
- package/core/runner.js +13 -6
- package/core/system-prompt.js +25 -4
- package/core/turn-observer.js +3 -2
- package/package.json +1 -1
- package/test-kazee-call-manager.js +4 -0
- package/test-kazee-call-session.js +75 -0
- package/test-provider-language.js +30 -1
- package/test-recall-engine.js +29 -0
- package/test-runner-watchdog-static.js +8 -0
- package/test-utility-provider-policy.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## v3.1.16 — CI-deflake: no live network in unit tests, CI-safe enforcer deadlines
|
|
4
11
|
|
|
5
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).
|
|
@@ -80,7 +80,7 @@ class JanusClient {
|
|
|
80
80
|
this.pending.delete(transaction);
|
|
81
81
|
reject(new Error(`Janus ${payload.janus} timed out`));
|
|
82
82
|
}, this.requestTimeoutMs);
|
|
83
|
-
this.pending.set(transaction, { resolve, reject, timer });
|
|
83
|
+
this.pending.set(transaction, { resolve, reject, timer, verb: payload.janus });
|
|
84
84
|
this.socket.send(JSON.stringify(frame), (error) => {
|
|
85
85
|
if (!error) return;
|
|
86
86
|
clearTimeout(timer);
|
|
@@ -108,7 +108,10 @@ class JanusClient {
|
|
|
108
108
|
try { frame = JSON.parse(raw.toString()); }
|
|
109
109
|
catch (_) { return; }
|
|
110
110
|
const pending = frame.transaction && this.pending.get(frame.transaction);
|
|
111
|
-
|
|
111
|
+
// Plugin requests get an ack then a real event, so acks must not settle
|
|
112
|
+
// them — but keepalive is answered with ONLY an ack, so waiting past it
|
|
113
|
+
// guaranteed a bogus "keepalive timed out" every interval.
|
|
114
|
+
if (pending && (frame.janus !== "ack" || pending.verb === "keepalive")) {
|
|
112
115
|
clearTimeout(pending.timer);
|
|
113
116
|
this.pending.delete(frame.transaction);
|
|
114
117
|
if (frame.janus === "error") pending.reject(new Error(frame.error?.reason || "Janus error"));
|
|
@@ -31,9 +31,13 @@ class KazeeCallManager {
|
|
|
31
31
|
const roomId = Number(payload.roomId);
|
|
32
32
|
const callType = String(payload.callType || "");
|
|
33
33
|
const owner = String(this.adapter.ownerUserId || "");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
console.log(`Kazee call incoming: room=${roomId} chat=${chatId} from=${callerId} type=${callType}`);
|
|
35
|
+
const reject = (reason) => {
|
|
36
|
+
console.log(`Kazee call rejected: room=${roomId} from=${callerId} reason=${reason}`);
|
|
37
|
+
this.adapter._socket?.emit("call:reject", {
|
|
38
|
+
toUserId: callerId, callType, chatId, roomId, reason,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
37
41
|
|
|
38
42
|
if (!callerId || !chatId || !Number.isSafeInteger(roomId)) return reject("invalid_call");
|
|
39
43
|
if (callerId !== owner) return reject("not_allowed");
|
|
@@ -49,6 +53,7 @@ class KazeeCallManager {
|
|
|
49
53
|
this.session = session;
|
|
50
54
|
try {
|
|
51
55
|
await session.start();
|
|
56
|
+
console.log(`Kazee call accepted: room=${roomId} from=${callerId}`);
|
|
52
57
|
this.adapter._socket?.emit("call:accept", {
|
|
53
58
|
toUserId: callerId, callType: "audio", chatId, roomId,
|
|
54
59
|
});
|
|
@@ -70,6 +75,11 @@ class KazeeCallManager {
|
|
|
70
75
|
// Provider narration/previews are useful in chat but must never be spoken
|
|
71
76
|
// into a call. Only the settled, guarded final answer reaches send().
|
|
72
77
|
callAdapter.suppressStreamPreview = true;
|
|
78
|
+
// Recall/tooltrace banners route through the same send() as the answer —
|
|
79
|
+
// without this flag the TTS reads memory-debug lines to the caller.
|
|
80
|
+
callAdapter.suppressChatVisibility = true;
|
|
81
|
+
// Prompt builder switches to phone-call brevity when it sees this.
|
|
82
|
+
callAdapter.liveCall = true;
|
|
73
83
|
callAdapter.send = async (_channelId, response) => {
|
|
74
84
|
await session.speak(response);
|
|
75
85
|
return true;
|
|
@@ -7,6 +7,12 @@ const { SpeechStream, synthesizePcm } = require("./speech-client");
|
|
|
7
7
|
|
|
8
8
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
9
|
|
|
10
|
+
// Per-stage call logging. The media leg (ICE/RTP) can fail with zero thrown
|
|
11
|
+
// errors — a caller then hears silence while the log shows a clean
|
|
12
|
+
// accept→close. Timestamped stage lines make that failure mode visible and
|
|
13
|
+
// double as the STT/TTS timing measurements chat-side estimates lacked.
|
|
14
|
+
const clog = (msg) => console.log(`${new Date().toISOString()} ${msg}`);
|
|
15
|
+
|
|
10
16
|
const BASE_THRESHOLD_DB = -38;
|
|
11
17
|
// While TTS plays, the caller's device can leak bot audio back through its mic.
|
|
12
18
|
// Require a much louder signal before treating incoming audio as human speech.
|
|
@@ -15,9 +21,38 @@ const PREBUFFER_FRAMES = 12; // 240ms of pre-onset audio so first syllables reac
|
|
|
15
21
|
const LIMIT_CHECK_MS = 10_000;
|
|
16
22
|
const DEFAULT_MAX_CALL_MS = 30 * 60_000;
|
|
17
23
|
const DEFAULT_IDLE_MS = 2 * 60_000;
|
|
24
|
+
const DEFAULT_JOIN_RETRIES = 10;
|
|
25
|
+
const DEFAULT_JOIN_RETRY_DELAY_MS = 500;
|
|
18
26
|
const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
|
|
19
27
|
const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
|
|
20
28
|
|
|
29
|
+
// Audible state cues: a short ack chime when the caller finishes speaking, then
|
|
30
|
+
// a soft periodic tick while the turn is being worked on — dead air otherwise
|
|
31
|
+
// reads as a dropped call.
|
|
32
|
+
const THINK_TICK_INTERVAL_MS = 1600;
|
|
33
|
+
const THINKING_MAX_MS = 5 * 60_000;
|
|
34
|
+
|
|
35
|
+
function tonePcm48(segments, gain = 0.08) {
|
|
36
|
+
const rate = 48000;
|
|
37
|
+
const fade = Math.floor(rate * 0.012);
|
|
38
|
+
const buffers = segments.map(({ freq, ms }) => {
|
|
39
|
+
const samples = Math.floor((rate * ms) / 1000);
|
|
40
|
+
const buf = Buffer.alloc(samples * 2);
|
|
41
|
+
for (let i = 0; i < samples; i += 1) {
|
|
42
|
+
let a = gain;
|
|
43
|
+
if (i < fade) a *= i / fade;
|
|
44
|
+
if (i > samples - fade) a *= Math.max(0, (samples - i) / fade);
|
|
45
|
+
buf.writeInt16LE(Math.round(Math.sin((2 * Math.PI * freq * i) / rate) * a * 32767), i * 2);
|
|
46
|
+
}
|
|
47
|
+
return buf;
|
|
48
|
+
});
|
|
49
|
+
return Buffer.concat(buffers);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Rising two-tone "heard you", and a quieter single "still here" pulse.
|
|
53
|
+
const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }], 0.09);
|
|
54
|
+
const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
55
|
+
|
|
21
56
|
class KazeeCallSession {
|
|
22
57
|
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
23
58
|
maxCallMs, idleMs, synthesize = synthesizePcm,
|
|
@@ -35,6 +70,8 @@ class KazeeCallSession {
|
|
|
35
70
|
this.synthesize = synthesize;
|
|
36
71
|
this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
|
|
37
72
|
this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
|
|
73
|
+
this.joinRetries = DEFAULT_JOIN_RETRIES;
|
|
74
|
+
this.joinRetryDelayMs = DEFAULT_JOIN_RETRY_DELAY_MS;
|
|
38
75
|
this.codec = new OpusAudioCodec();
|
|
39
76
|
this.publisher = null;
|
|
40
77
|
this.publisherHandle = null;
|
|
@@ -49,6 +86,8 @@ class KazeeCallSession {
|
|
|
49
86
|
this._lastSpokeAt = 0;
|
|
50
87
|
this._limitTimer = null;
|
|
51
88
|
this._ending = false;
|
|
89
|
+
this._thinkTimer = null;
|
|
90
|
+
this._thinkingSince = 0;
|
|
52
91
|
this.sequence = Math.floor(Math.random() * 65535);
|
|
53
92
|
this.timestamp = Math.floor(Math.random() * 0xffffffff);
|
|
54
93
|
this.ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
@@ -58,9 +97,20 @@ class KazeeCallSession {
|
|
|
58
97
|
await this.janus.connect();
|
|
59
98
|
this.publisherHandle = await this.janus.attach();
|
|
60
99
|
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
// The caller creates the Janus room during its own call setup, which can
|
|
101
|
+
// land a beat after the invite reaches us — and a bot answers faster than
|
|
102
|
+
// any human. Poll join through transient 426 "No such room" like the web
|
|
103
|
+
// receiver's retry loop in CallContext.startVideoRoom.
|
|
104
|
+
let joined;
|
|
105
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
106
|
+
joined = await this.janus.message(this.publisherHandle, {
|
|
107
|
+
request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
108
|
+
});
|
|
109
|
+
const data = joined?.plugindata?.data || {};
|
|
110
|
+
const noRoom = Number(data.error_code) === 426 || /no such room/i.test(String(data.error || ""));
|
|
111
|
+
if (!noRoom || attempt >= this.joinRetries || this.closed) break;
|
|
112
|
+
await wait(this.joinRetryDelayMs);
|
|
113
|
+
}
|
|
64
114
|
await this._consumePublisherFrame(joined);
|
|
65
115
|
this._startedAt = Date.now();
|
|
66
116
|
this._lastHeard = this._startedAt;
|
|
@@ -76,14 +126,21 @@ class KazeeCallSession {
|
|
|
76
126
|
|
|
77
127
|
async _consumePublisherFrame(frame) {
|
|
78
128
|
const data = frame?.plugindata?.data || {};
|
|
79
|
-
if (data.error)
|
|
129
|
+
if (data.error) {
|
|
130
|
+
const error = new Error(data.error);
|
|
131
|
+
if (data.error_code != null) error.code = Number(data.error_code);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
80
134
|
for (const publisher of data.publishers || []) {
|
|
81
135
|
const feedId = Number(publisher.id);
|
|
82
136
|
if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
83
137
|
await this._subscribe(feedId, publisher.display);
|
|
84
138
|
}
|
|
85
139
|
}
|
|
86
|
-
if (data.videoroom === "joined"
|
|
140
|
+
if (data.videoroom === "joined") {
|
|
141
|
+
clog(`Kazee call room joined: room=${this.roomId} publishers=${(data.publishers || []).length}`);
|
|
142
|
+
if (!this.publisher) await this._publish();
|
|
143
|
+
}
|
|
87
144
|
const gone = Number(data.leaving || data.unpublished || 0);
|
|
88
145
|
if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
|
|
89
146
|
if (data.videoroom === "destroyed") await this.close("room-destroyed");
|
|
@@ -92,6 +149,7 @@ class KazeeCallSession {
|
|
|
92
149
|
async _publish() {
|
|
93
150
|
if (this.publisher) return;
|
|
94
151
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
152
|
+
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`));
|
|
95
153
|
const track = new MediaStreamTrack({ kind: "audio" });
|
|
96
154
|
const sender = pc.addTrack(track);
|
|
97
155
|
this.publisher = { pc, track, sender };
|
|
@@ -101,11 +159,14 @@ class KazeeCallSession {
|
|
|
101
159
|
const answer = configured?.jsep;
|
|
102
160
|
if (!answer) throw new Error("Janus publisher configure did not return SDP");
|
|
103
161
|
await pc.setRemoteDescription(answer);
|
|
162
|
+
clog(`Kazee call publisher configured: room=${this.roomId}`);
|
|
104
163
|
}
|
|
105
164
|
|
|
106
165
|
async _subscribe(feedId, display) {
|
|
166
|
+
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
107
167
|
const handle = await this.janus.attach();
|
|
108
168
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
169
|
+
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
109
170
|
const sub = {
|
|
110
171
|
handle,
|
|
111
172
|
pc,
|
|
@@ -123,7 +184,10 @@ class KazeeCallSession {
|
|
|
123
184
|
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
124
185
|
const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
|
|
125
186
|
await this._consumeSubscriberFrame(feedId, attached);
|
|
126
|
-
await sub.stt.connect().
|
|
187
|
+
await sub.stt.connect().then(
|
|
188
|
+
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
189
|
+
(error) => console.error("Kazee call STT connect:", error.message),
|
|
190
|
+
);
|
|
127
191
|
}
|
|
128
192
|
|
|
129
193
|
async _onSubscriberEvent(feedId, frame) {
|
|
@@ -135,18 +199,29 @@ class KazeeCallSession {
|
|
|
135
199
|
const sub = this.subscribers.get(feedId);
|
|
136
200
|
if (!sub) return;
|
|
137
201
|
const data = frame?.plugindata?.data || {};
|
|
138
|
-
if (data.error)
|
|
202
|
+
if (data.error) {
|
|
203
|
+
const error = new Error(data.error);
|
|
204
|
+
if (data.error_code != null) error.code = Number(data.error_code);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
139
207
|
if (frame.jsep && !sub.started) {
|
|
140
208
|
sub.started = true;
|
|
141
209
|
await sub.pc.setRemoteDescription(frame.jsep);
|
|
142
210
|
const answer = await sub.pc.createAnswer();
|
|
143
211
|
await sub.pc.setLocalDescription(answer);
|
|
144
212
|
await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
|
|
213
|
+
clog(`Kazee call subscriber started: feed=${feedId}`);
|
|
145
214
|
}
|
|
146
215
|
}
|
|
147
216
|
|
|
148
217
|
_onFeedTranscript(feedId, text) {
|
|
149
218
|
const sub = this.subscribers.get(feedId);
|
|
219
|
+
const len = String(text || "").trim().length;
|
|
220
|
+
clog(`Kazee call transcript: feed=${feedId} chars=${len}${len ? "" : " (noise commit)"}`);
|
|
221
|
+
if (String(text || "").trim()) this._startThinking();
|
|
222
|
+
// Empty finalization (noise commit): nothing dispatches, no reply is
|
|
223
|
+
// coming — don't tick into the void.
|
|
224
|
+
else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
|
|
150
225
|
this.onTranscript(text, {
|
|
151
226
|
feedId,
|
|
152
227
|
display: sub?.display || "",
|
|
@@ -154,10 +229,49 @@ class KazeeCallSession {
|
|
|
154
229
|
});
|
|
155
230
|
}
|
|
156
231
|
|
|
232
|
+
// Writes a short cue into the outbound track. Yields to real speech: the
|
|
233
|
+
// moment an utterance is active (or starting), remaining frames are dropped.
|
|
234
|
+
async _playCue(pcm48) {
|
|
235
|
+
if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
|
|
236
|
+
for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
|
|
237
|
+
if (this.closed || this.speaking || this._speakActive) return;
|
|
238
|
+
let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
|
|
239
|
+
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
240
|
+
const opus = this.codec.encode(frame);
|
|
241
|
+
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
242
|
+
this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
|
|
243
|
+
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
244
|
+
}), opus));
|
|
245
|
+
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
246
|
+
await wait(20);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_startThinking() {
|
|
251
|
+
if (this.closed || this._thinkTimer) return;
|
|
252
|
+
this._thinkingSince = Date.now();
|
|
253
|
+
this._thinkTimer = setInterval(() => {
|
|
254
|
+
if (this.closed || Date.now() - this._thinkingSince > THINKING_MAX_MS) return this._stopThinking();
|
|
255
|
+
if (this.speaking || this._speakActive) return;
|
|
256
|
+
this._playCue(THINK_TICK_PCM48).catch(() => {});
|
|
257
|
+
}, THINK_TICK_INTERVAL_MS);
|
|
258
|
+
if (typeof this._thinkTimer.unref === "function") this._thinkTimer.unref();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_stopThinking() {
|
|
262
|
+
if (!this._thinkTimer) return;
|
|
263
|
+
clearInterval(this._thinkTimer);
|
|
264
|
+
this._thinkTimer = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
157
267
|
_onFeedRtp(feedId, packet) {
|
|
158
268
|
if (this.closed || !packet?.payload?.length) return;
|
|
159
269
|
const sub = this.subscribers.get(feedId);
|
|
160
270
|
if (!sub) return;
|
|
271
|
+
if (!sub.firstRtpAt) {
|
|
272
|
+
sub.firstRtpAt = Date.now();
|
|
273
|
+
clog(`Kazee call first RTP: feed=${feedId}`);
|
|
274
|
+
}
|
|
161
275
|
let pcm;
|
|
162
276
|
try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
|
|
163
277
|
sub.vad.thresholdDb = this.speaking ? SPEAKING_THRESHOLD_DB : BASE_THRESHOLD_DB;
|
|
@@ -170,14 +284,21 @@ class KazeeCallSession {
|
|
|
170
284
|
}
|
|
171
285
|
if (event?.type === "start") {
|
|
172
286
|
this._lastHeard = Date.now();
|
|
287
|
+
clog(`Kazee call VAD start: feed=${feedId}`);
|
|
173
288
|
// Barge-in: the human wins — stop talking and listen.
|
|
174
289
|
if (this.speaking) this.cancelSpeech();
|
|
290
|
+
this._stopThinking();
|
|
175
291
|
for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
|
|
176
292
|
}
|
|
177
293
|
this._feedAudio(sub, pcm16);
|
|
178
294
|
if (event?.type === "commit") {
|
|
179
295
|
this._lastHeard = Date.now();
|
|
296
|
+
clog(`Kazee call VAD commit: feed=${feedId}`);
|
|
180
297
|
sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
|
|
298
|
+
this._playCue(ACK_CHIME_PCM48).catch(() => {});
|
|
299
|
+
// STT finalization takes seconds; start ticking at commit so the
|
|
300
|
+
// chime→first-tick stretch doesn't read as a dead line.
|
|
301
|
+
this._startThinking();
|
|
181
302
|
}
|
|
182
303
|
}
|
|
183
304
|
|
|
@@ -218,9 +339,12 @@ class KazeeCallSession {
|
|
|
218
339
|
}
|
|
219
340
|
|
|
220
341
|
async _playUtterance(text, gen) {
|
|
342
|
+
const synthStart = Date.now();
|
|
221
343
|
const synthesized = await this.synthesize(text);
|
|
222
344
|
if (this.closed || gen !== this._speakGen) return false;
|
|
223
345
|
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
346
|
+
clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
347
|
+
this._stopThinking();
|
|
224
348
|
this.speaking = true;
|
|
225
349
|
try {
|
|
226
350
|
for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
|
|
@@ -245,7 +369,9 @@ class KazeeCallSession {
|
|
|
245
369
|
if (this.closed || this._ending) return;
|
|
246
370
|
const now = Date.now();
|
|
247
371
|
const overMax = now - this._startedAt >= this.maxCallMs;
|
|
248
|
-
|
|
372
|
+
// An audible thinking loop means a turn is still being worked on — that is
|
|
373
|
+
// not idleness, or long provider turns would get hung up on mid-answer.
|
|
374
|
+
const speechIdle = !this.speaking && !this._speakActive && !this._speakQueue.length && !this._thinkTimer;
|
|
249
375
|
const idle = speechIdle && now - Math.max(this._lastHeard, this._lastSpokeAt) >= this.idleMs;
|
|
250
376
|
if (!overMax && !idle) return;
|
|
251
377
|
this._ending = true;
|
|
@@ -272,7 +398,9 @@ class KazeeCallSession {
|
|
|
272
398
|
async close(reason = "ended") {
|
|
273
399
|
if (this.closed) return;
|
|
274
400
|
this.closed = true;
|
|
401
|
+
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
275
402
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
403
|
+
this._stopThinking();
|
|
276
404
|
this.cancelSpeech();
|
|
277
405
|
for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
|
|
278
406
|
try { await this.publisher?.pc?.close(); } catch (_) {}
|
package/core/handlers.js
CHANGED
|
@@ -1166,7 +1166,7 @@ register({
|
|
|
1166
1166
|
});
|
|
1167
1167
|
|
|
1168
1168
|
register({
|
|
1169
|
-
name: "engine", description: "Switch memory recall engine", args: "[classic|discoverer]",
|
|
1169
|
+
name: "engine", description: "Switch memory recall engine", args: "[classic|discoverer|ask]",
|
|
1170
1170
|
handler: async (env, { tail }) => {
|
|
1171
1171
|
if (!authorized(env)) return;
|
|
1172
1172
|
const { settings } = currentState();
|
|
@@ -1183,9 +1183,11 @@ register({
|
|
|
1183
1183
|
send(
|
|
1184
1184
|
`Recall engine: ${active}${settings.recallEngine ? "" : " (default)"}\n\n` +
|
|
1185
1185
|
"• discoverer — typed-edge graph + spreading activation + why-bullets (default)\n" +
|
|
1186
|
-
"• classic — keyword FTS + relevance judge (opt-out fallback)"
|
|
1186
|
+
"• classic — keyword FTS + relevance judge (opt-out fallback)\n" +
|
|
1187
|
+
"• ask — nothing auto-injected; I pull my own notes on demand and say so",
|
|
1187
1188
|
{ keyboard: { inline_keyboard: [
|
|
1188
1189
|
[{ text: "Discoverer (default)", callback_data: "eng:discoverer" }, { text: "Classic", callback_data: "eng:classic" }],
|
|
1190
|
+
[{ text: "Ask (on-demand)", callback_data: "eng:ask" }],
|
|
1189
1191
|
] } },
|
|
1190
1192
|
);
|
|
1191
1193
|
},
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Ask recall engine: no automatic memory injection. Recall becomes something
|
|
2
|
+
// the working model ASKS for instead of something done to it every turn.
|
|
3
|
+
//
|
|
4
|
+
// Why: the push engines' own KPIs (recall-kpi.jsonl, 3 weeks to 2026-07-23)
|
|
5
|
+
// graded 51-89% of injected packs as noise, only ~7% of injections were ever
|
|
6
|
+
// opened, and the discoverer walk taxed full-tier turns 10-16s. Meanwhile the
|
|
7
|
+
// jobs that genuinely need always-on delivery (lessons, speaker notes) never
|
|
8
|
+
// lived in this pipeline. So this engine injects nothing and instead tells
|
|
9
|
+
// the model how to pull: `open-claudia recall query` (the same seed → graph →
|
|
10
|
+
// walker pipeline, run on demand with the model's OWN question) plus the
|
|
11
|
+
// direct pack/entity/transcript commands.
|
|
12
|
+
//
|
|
13
|
+
// The block below is the entire per-turn cost: identical every turn, a few
|
|
14
|
+
// hundred bytes, zero matching, zero walker spawn, zero latency.
|
|
15
|
+
|
|
16
|
+
const ASK_BLOCK = [
|
|
17
|
+
"## Memory (on-demand mode)",
|
|
18
|
+
"No long-term memory was auto-injected this turn. Your notes (context packs, entities, tools, past conversations) are available when YOU decide they're needed:",
|
|
19
|
+
"- `open-claudia recall query \"<question>\" [--context \"<working context>\"] [--json]` — graph walk + cheap-model judgement over the whole memory corpus; returns relevant notes with why + pointers.",
|
|
20
|
+
"- Direct: `open-claudia pack show <dir>` · `entity show <slug>` · `tool search <query>` · `transcript-search \"<query>\"`.",
|
|
21
|
+
"Ask whenever the topic plausibly has history — a person, project, preference, or prior decision you can't fully place. When you do, say so in one short line (e.g. \"taking a look at my notes on X\") so the user sees the lookup.",
|
|
22
|
+
"",
|
|
23
|
+
"",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
async function run(ctx) {
|
|
27
|
+
try {
|
|
28
|
+
require("./metrics").logTurn({
|
|
29
|
+
engine: "ask",
|
|
30
|
+
provider: ctx?.provider || "",
|
|
31
|
+
model: "",
|
|
32
|
+
query: String(ctx?.userText || ""),
|
|
33
|
+
tier: "ask",
|
|
34
|
+
seeds: [],
|
|
35
|
+
activated: [],
|
|
36
|
+
cand: [],
|
|
37
|
+
kept: [],
|
|
38
|
+
gated: false,
|
|
39
|
+
latencyMs: 0,
|
|
40
|
+
walkerMs: 0,
|
|
41
|
+
walker: "off",
|
|
42
|
+
costUsd: 0,
|
|
43
|
+
});
|
|
44
|
+
} catch (e) { /* metrics continuity is best-effort */ }
|
|
45
|
+
return { packBlock: ASK_BLOCK, entityBlock: "", packMatches: [], entityMatches: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { name: "ask", run, ASK_BLOCK };
|
package/core/recall/index.js
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
// to the RECALL_ENGINE env var, then to DEFAULT_ENGINE. Unknown names fall
|
|
8
8
|
// back to the default so a bad value can never break recall. The discoverer is
|
|
9
9
|
// the default: it's fail-open (falls back to the classic keyword baseline on
|
|
10
|
-
// any error), so it never recalls worse than classic, only better.
|
|
10
|
+
// any error), so it never recalls worse than classic, only better. `ask`
|
|
11
|
+
// injects nothing and points the model at on-demand recall instead
|
|
12
|
+
// (`open-claudia recall query`) — pull, not push.
|
|
11
13
|
|
|
12
14
|
const classic = require("./classic");
|
|
13
15
|
const discoverer = require("./discoverer");
|
|
16
|
+
const ask = require("./ask");
|
|
14
17
|
|
|
15
18
|
const ENGINES = {
|
|
16
19
|
classic,
|
|
17
20
|
discoverer,
|
|
21
|
+
ask,
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
// The default recall engine for channels that haven't explicitly chosen one
|
package/core/runner.js
CHANGED
|
@@ -224,6 +224,13 @@ function telegramHtmlOpts(extra = {}) {
|
|
|
224
224
|
return adapter?.type === "telegram" ? { ...extra, parseMode: "HTML" } : extra;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// Status chatter (queue acks, usage alerts, reviewer/guardrail notes) must not
|
|
228
|
+
// reach an adapter whose send() is spoken aloud (live call turns) — only the
|
|
229
|
+
// settled reply belongs on the line.
|
|
230
|
+
function statusChatterMuted() {
|
|
231
|
+
try { return currentAdapter()?.suppressChatVisibility === true; } catch (e) { return false; }
|
|
232
|
+
}
|
|
233
|
+
|
|
227
234
|
function stableRunError(message, code = "PROVIDER_RUN_FAILED", details = {}) {
|
|
228
235
|
const error = new Error(redactSensitive(String(message || "Provider run failed")));
|
|
229
236
|
error.name = "ProviderRunError";
|
|
@@ -1523,7 +1530,7 @@ async function persistForegroundUsage(result, runContext, state = currentState()
|
|
|
1523
1530
|
state,
|
|
1524
1531
|
usage,
|
|
1525
1532
|
result.costUsd || 0,
|
|
1526
|
-
(text) => chatContext.run(store, () => send(text)),
|
|
1533
|
+
(text) => chatContext.run(store, () => (statusChatterMuted() ? true : send(text))),
|
|
1527
1534
|
{
|
|
1528
1535
|
backend: runContext.provider,
|
|
1529
1536
|
model: runContext.providerSettings?.model || null,
|
|
@@ -1925,7 +1932,7 @@ async function handoffMemoryReview(result, runContext, store, state) {
|
|
|
1925
1932
|
return speaker ? { name: speaker.name, isOwner: !!speaker.isOwner } : null;
|
|
1926
1933
|
} catch (_) { return null; }
|
|
1927
1934
|
})(),
|
|
1928
|
-
announce: showRecall ? (text) => chatContext.run(store, () => send(text)) : null,
|
|
1935
|
+
announce: showRecall ? (text) => chatContext.run(store, () => (statusChatterMuted() ? true : send(text))) : null,
|
|
1929
1936
|
});
|
|
1930
1937
|
}
|
|
1931
1938
|
|
|
@@ -1941,7 +1948,7 @@ async function considerGuardrail(result, runContext, store) {
|
|
|
1941
1948
|
const relationship = require("./relationship");
|
|
1942
1949
|
const speaker = relationship.speakerFor(store?.adapter?.type, store?.channelId, store?.userId);
|
|
1943
1950
|
if (relationship.guardActive(speaker)) return; // external speakers never capture
|
|
1944
|
-
const announce = (text, opts) => chatContext.run(store, () => send(text, opts));
|
|
1951
|
+
const announce = (text, opts) => chatContext.run(store, () => (statusChatterMuted() ? true : send(text, opts)));
|
|
1945
1952
|
await guardrail.consider({ text: runContext.userPrompt, speaker, announce });
|
|
1946
1953
|
} catch (e) { /* best-effort — a guardrail miss must never break a turn */ }
|
|
1947
1954
|
}
|
|
@@ -2132,9 +2139,9 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2132
2139
|
state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
|
|
2133
2140
|
// Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
|
|
2134
2141
|
// On a Spaces task thread they'd post a throwaway "Queued." comment on the
|
|
2135
|
-
// task — noise.
|
|
2136
|
-
//
|
|
2137
|
-
if (currentAdapter()?.type !== "spaces") {
|
|
2142
|
+
// task — noise. On a live call, send() is the TTS line: "Queued." would be
|
|
2143
|
+
// spoken to the caller. Both stay quiet and just deliver the real reply.
|
|
2144
|
+
if (currentAdapter()?.type !== "spaces" && !statusChatterMuted()) {
|
|
2138
2145
|
send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId }).catch(() => {});
|
|
2139
2146
|
}
|
|
2140
2147
|
return deferred.promise;
|
package/core/system-prompt.js
CHANGED
|
@@ -361,7 +361,9 @@ function buildSpeakerContextBlock() {
|
|
|
361
361
|
: "unscoped chat";
|
|
362
362
|
const blocks = [
|
|
363
363
|
"## Channel context",
|
|
364
|
-
|
|
364
|
+
adapter?.liveCall === true
|
|
365
|
+
? "- Interface: LIVE VOICE CALL on Kazee — the user is speaking on an open line; replies are spoken aloud to them."
|
|
366
|
+
: `- Interface: ${channelLabel} chat through Open Claudia.`,
|
|
365
367
|
];
|
|
366
368
|
|
|
367
369
|
const speaker = adapter && channelId
|
|
@@ -984,6 +986,16 @@ function bumpFtsMissCounter(n) {
|
|
|
984
986
|
// prompt) ONLY on voice-input turns of the voice channel — i.e. exactly when the
|
|
985
987
|
// reply will be read aloud by TTS (same gate as the streaming/auto-speak path in
|
|
986
988
|
// the runner). Text channels (Telegram/Kazee) and typed turns never see it.
|
|
989
|
+
const LIVE_CALL_GUIDANCE = [
|
|
990
|
+
"",
|
|
991
|
+
"",
|
|
992
|
+
"## Live voice call",
|
|
993
|
+
"You are on a LIVE voice call with the user right now. Their speech arrives transcribed; your final reply is synthesized straight onto the open line. Write it to be HEARD:",
|
|
994
|
+
"- Phone-call style: one to three short conversational sentences, then stop. No markdown, bullets, headings, emoji, backticks, or asterisks — they are noise when spoken.",
|
|
995
|
+
"- Do NOT read out code, file paths, command syntax, IDs, or URLs. Describe them in plain words, or offer to send the details to the chat as text.",
|
|
996
|
+
"- Tool time is dead air the caller sits through. Prefer answering from what you already know; keep any lookup to a couple of seconds, and offer to do longer digging after the call.",
|
|
997
|
+
].join("\n");
|
|
998
|
+
|
|
987
999
|
const VOICE_REPLY_GUIDANCE = [
|
|
988
1000
|
"",
|
|
989
1001
|
"",
|
|
@@ -1012,7 +1024,10 @@ const VOICE_NOTE_GUIDANCE = [
|
|
|
1012
1024
|
|
|
1013
1025
|
function voiceReplyGuidance(opts = {}) {
|
|
1014
1026
|
try {
|
|
1015
|
-
const { currentTransport } = require("./context");
|
|
1027
|
+
const { currentTransport, currentAdapter } = require("./context");
|
|
1028
|
+
// A live Kazee call: the reply is spoken onto the open line, so call-style
|
|
1029
|
+
// brevity outranks every chat-formatting rule.
|
|
1030
|
+
if (currentAdapter()?.liveCall === true) return LIVE_CALL_GUIDANCE;
|
|
1016
1031
|
const state = promptState(opts);
|
|
1017
1032
|
if (currentTransport() === "voice") return state.lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
|
|
1018
1033
|
const { VOICE_REPLY_MODE } = require("./config");
|
|
@@ -1054,7 +1069,13 @@ async function buildOptionalRecallContext(prompt, opts = {}) {
|
|
|
1054
1069
|
const budget = memoryRecallBudget();
|
|
1055
1070
|
const settings = promptState(opts).settings || {};
|
|
1056
1071
|
const recall = require("./recall");
|
|
1057
|
-
|
|
1072
|
+
// Live call: the discoverer's LLM walk is many seconds of dead air the
|
|
1073
|
+
// caller sits through, so calls downgrade it to classic (~50ms keyword
|
|
1074
|
+
// baseline). The ask engine is already zero-latency (injects nothing, model
|
|
1075
|
+
// pulls on demand and narrates the lookup), so it rides through unchanged.
|
|
1076
|
+
const activeName = recall.activeEngineName(settings);
|
|
1077
|
+
const engineName = currentAdapter()?.liveCall === true && activeName === "discoverer" ? "classic" : activeName;
|
|
1078
|
+
const engine = recall.getEngine(engineName);
|
|
1058
1079
|
const recallOpts = { ...opts, recallCollector: collector };
|
|
1059
1080
|
const recallProvider = promptRunIdentity({ ...opts, state: promptState(opts) }).provider;
|
|
1060
1081
|
const helpers = {
|
|
@@ -1078,7 +1099,7 @@ async function buildOptionalRecallContext(prompt, opts = {}) {
|
|
|
1078
1099
|
const why = result.why || {};
|
|
1079
1100
|
collector.packDirs = (result.packMatches || []).map((match) => match.dir).filter(Boolean);
|
|
1080
1101
|
collector.recall = {
|
|
1081
|
-
engine: engine.name ||
|
|
1102
|
+
engine: engine.name || engineName,
|
|
1082
1103
|
gated: !!result.gated,
|
|
1083
1104
|
tier: result.tier || "",
|
|
1084
1105
|
packs: (result.packMatches || []).map((match) => ({ name: match.name || match.dir, why: why[`pack:${match.dir}`] || "" })),
|
package/core/turn-observer.js
CHANGED
|
@@ -38,11 +38,12 @@ function createTurnObserver({ state, store }) {
|
|
|
38
38
|
// Spaces is a customer-facing surface that renders neither Telegram HTML nor
|
|
39
39
|
// internal recall/tool banners meaningfully — a <b> banner shows the literal
|
|
40
40
|
// tags. Suppress all owner-only chat visibility there; memory + graph feeding
|
|
41
|
-
// still run, only the chat emissions are muted.
|
|
41
|
+
// still run, only the chat emissions are muted. Adapters may also opt out
|
|
42
|
+
// explicitly (voice calls: send() feeds TTS, so a banner would be spoken).
|
|
42
43
|
let suppressChatVisibility = false;
|
|
43
44
|
try {
|
|
44
45
|
const adapter = chatContext.run(store, () => currentAdapter());
|
|
45
|
-
suppressChatVisibility = adapter?.type === "spaces";
|
|
46
|
+
suppressChatVisibility = adapter?.type === "spaces" || adapter?.suppressChatVisibility === true;
|
|
46
47
|
} catch (e) { suppressChatVisibility = false; }
|
|
47
48
|
|
|
48
49
|
const packsLib = require("./packs");
|
package/package.json
CHANGED
|
@@ -28,6 +28,10 @@ function fixture() {
|
|
|
28
28
|
assert.strictEqual(f.events[0].name, "call:accept");
|
|
29
29
|
f.manager.session.options.onTranscript("What is on my agenda?");
|
|
30
30
|
assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
|
|
31
|
+
// Call turns must mute observer banners (spoken otherwise) and flag live-call
|
|
32
|
+
// mode so the prompt builder switches to phone-style brevity.
|
|
33
|
+
assert.strictEqual(f.inbound[0].payload.adapter.suppressChatVisibility, true);
|
|
34
|
+
assert.strictEqual(f.inbound[0].payload.adapter.liveCall, true);
|
|
31
35
|
await f.inbound[0].payload.adapter.send("chat", "Your agenda is clear.");
|
|
32
36
|
assert.strictEqual(f.manager.session.spoken, "Your agenda is clear.");
|
|
33
37
|
await f.manager.ended({ roomId: 12 });
|
|
@@ -202,6 +202,78 @@ 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
|
+
|
|
231
|
+
async function testThinkingCues() {
|
|
232
|
+
const { session } = makeSession({
|
|
233
|
+
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 3), sampleRate: 48000 }),
|
|
234
|
+
});
|
|
235
|
+
let frames = 0;
|
|
236
|
+
session.publisher = { track: { writeRtp: () => { frames += 1; } }, sender: { codec: { payloadType: 111 } } };
|
|
237
|
+
await session._subscribe(101, "caller");
|
|
238
|
+
const sub = session.subscribers.get(101);
|
|
239
|
+
const gen = new OpusAudioCodec();
|
|
240
|
+
|
|
241
|
+
// Caller finishes a sentence: speech then sustained silence commits the turn,
|
|
242
|
+
// plays an audible "heard you" chime, and starts ticking right away — STT
|
|
243
|
+
// finalization takes seconds and must not sound like a dead line.
|
|
244
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
245
|
+
for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
|
|
246
|
+
assert.strictEqual(sub.stt.commits, 1);
|
|
247
|
+
await wait(60);
|
|
248
|
+
assert(frames > 0, "commit plays the ack chime");
|
|
249
|
+
assert(session._thinkTimer, "thinking loop starts at commit, before the transcript");
|
|
250
|
+
|
|
251
|
+
// A noise commit finalizes to empty text: nothing dispatches, so the ticks
|
|
252
|
+
// stop rather than promising an answer that will never come.
|
|
253
|
+
sub.stt.onTranscript(" ");
|
|
254
|
+
assert.strictEqual(session._thinkTimer, null, "empty transcript stops the thinking loop");
|
|
255
|
+
|
|
256
|
+
// Real transcript dispatch (re)starts the loop; it counts as activity for
|
|
257
|
+
// the idle check, and the answer's playback stops it.
|
|
258
|
+
sub.stt.onTranscript("question");
|
|
259
|
+
assert(session._thinkTimer, "thinking loop starts on transcript");
|
|
260
|
+
session._startedAt = Date.now();
|
|
261
|
+
session._lastHeard = Date.now() - 10_000;
|
|
262
|
+
session._lastSpokeAt = Date.now() - 10_000;
|
|
263
|
+
session.idleMs = 50;
|
|
264
|
+
await session._enforceLimits();
|
|
265
|
+
assert.strictEqual(session.closed, false, "audible thinking is not idleness");
|
|
266
|
+
await session.speak("answer");
|
|
267
|
+
assert.strictEqual(session._thinkTimer, null, "answer playback stops the thinking loop");
|
|
268
|
+
|
|
269
|
+
// A fresh thinking loop stops the moment the caller starts talking again.
|
|
270
|
+
session._startThinking();
|
|
271
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
272
|
+
assert.strictEqual(session._thinkTimer, null, "caller speech stops the thinking loop");
|
|
273
|
+
gen.close();
|
|
274
|
+
await session.close();
|
|
275
|
+
}
|
|
276
|
+
|
|
205
277
|
async function testCloseResolvesSpeech() {
|
|
206
278
|
const { session } = makeSession({
|
|
207
279
|
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
|
|
@@ -226,6 +298,9 @@ async function testCloseResolvesSpeech() {
|
|
|
226
298
|
await testIdleTimeout();
|
|
227
299
|
await testIdleBargeInKeepsCall();
|
|
228
300
|
await testMaxDuration();
|
|
301
|
+
await testJoinRetriesNoSuchRoom();
|
|
302
|
+
await testJoinGivesUpAfterRetries();
|
|
303
|
+
await testThinkingCues();
|
|
229
304
|
await testCloseResolvesSpeech();
|
|
230
305
|
console.log("kazee call session: ok");
|
|
231
306
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -222,7 +222,36 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
222
222
|
// deflake — heal-guard test stubs its live api.telegram.org probe and isolates
|
|
223
223
|
// CONFIG_DIR; enforcer probe deadlines raised to CI-safe values (2000→8000ms
|
|
224
224
|
// backstop, 2500→5000ms deliberate-timeout boot headroom). No runtime changes.
|
|
225
|
-
|
|
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
|
+
// 3.1.18 approved by Sumeet 2026-07-23 ("Yes those three make sense but I
|
|
231
|
+
// think we need a better UI for the calls with AI… an audio sound to show
|
|
232
|
+
// listening and thinking", then "still stuff like queued and token usage alert
|
|
233
|
+
// etc came through"): live-call hygiene + audible UX — recall/tooltrace
|
|
234
|
+
// banners, queue acks, usage alerts, and reviewer/guardrail notes are never
|
|
235
|
+
// spoken into a call; a phone-style live-call prompt mode; call lifecycle
|
|
236
|
+
// logging; ack chime + thinking ticks starting at VAD commit; and call turns
|
|
237
|
+
// pin classic recall so memory lookup stops costing ~17s of dead air.
|
|
238
|
+
// 3.1.19 approved by Sumeet 2026-07-23 ("can we make it a flag so the recall
|
|
239
|
+
// mode has this as an option", after auditing push recall's own KPIs: 51-89%
|
|
240
|
+
// noise, ~7% of injections ever opened, 10-16s full-tier latency tax): new
|
|
241
|
+
// `ask` recall engine — /engine ask injects nothing per turn and instead
|
|
242
|
+
// instructs the model to pull its own memory on demand via `open-claudia
|
|
243
|
+
// recall query` and narrate the lookup; live-call pin now downgrades only the
|
|
244
|
+
// discoverer to classic, so ask rides through calls at zero latency.
|
|
245
|
+
// 3.1.20 approved by Sumeet 2026-07-23 ("We will come back to the voice stuff
|
|
246
|
+
// just push out what we have as a new version"): call per-stage media
|
|
247
|
+
// diagnostics — ISO-timestamped stage logs (ICE states, room-join publisher
|
|
248
|
+
// count, subscribe/start, first RTP, VAD start/commit, STT connect, transcript
|
|
249
|
+
// length, TTS synth ms) so a silent media-leg failure (accepted → nothing
|
|
250
|
+
// heard → remote-ended with zero errors) becomes visible and STT/TTS timings
|
|
251
|
+
// stop being estimates — plus the Janus keepalive ack fix (Janus answers
|
|
252
|
+
// keepalive with ONLY an ack; acks now settle keepalive requests alone,
|
|
253
|
+
// ending the bogus per-interval "keepalive timed out" log noise).
|
|
254
|
+
assert.strictEqual(pkg.version, "3.1.20", "release version must remain unchanged without explicit approval");
|
|
226
255
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
227
256
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
228
257
|
}
|
package/test-recall-engine.js
CHANGED
|
@@ -10,9 +10,38 @@ assert.strictEqual(recall.activeEngineName({ recallEngine: "classic" }), "classi
|
|
|
10
10
|
assert.strictEqual(recall.activeEngineName({ recallEngine: "nope" }), "discoverer", "unknown → default");
|
|
11
11
|
assert.strictEqual(recall.activeEngineName({ recallEngine: "CLASSIC" }), "classic", "case-insensitive");
|
|
12
12
|
assert.ok(recall.listEngines().includes("classic"));
|
|
13
|
+
assert.ok(recall.listEngines().includes("ask"), "ask engine must be selectable");
|
|
14
|
+
assert.strictEqual(recall.activeEngineName({ recallEngine: "ask" }), "ask", "ask selectable per channel");
|
|
13
15
|
assert.strictEqual(typeof recall.getEngine("classic").run, "function");
|
|
14
16
|
assert.strictEqual(recall.getEngine("bogus").name, "classic", "getEngine hard-floor stays classic (crash-proof)");
|
|
15
17
|
|
|
18
|
+
// Live-call fast-path: the discoverer's LLM walk is dead air a caller sits
|
|
19
|
+
// through, so call turns downgrade discoverer→classic — but the ask engine
|
|
20
|
+
// (zero injection, zero latency) must ride through unchanged.
|
|
21
|
+
{
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const src = fs.readFileSync(require.resolve("./core/system-prompt.js"), "utf8");
|
|
24
|
+
assert.match(
|
|
25
|
+
src,
|
|
26
|
+
/liveCall === true && activeName === "discoverer" \? "classic" : activeName/,
|
|
27
|
+
"live-call turns must downgrade only the discoverer, preserving ask",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- ask engine: injects nothing but the on-demand instructions ---
|
|
32
|
+
(async () => {
|
|
33
|
+
const ask = recall.getEngine("ask");
|
|
34
|
+
const out = await ask.run({ userText: "hello", provider: "claude" });
|
|
35
|
+
assert.strictEqual(out.packMatches.length, 0, "ask injects no packs");
|
|
36
|
+
assert.strictEqual(out.entityMatches.length, 0, "ask injects no entities");
|
|
37
|
+
assert.strictEqual(out.entityBlock, "", "ask has no entity block");
|
|
38
|
+
assert.match(out.packBlock, /on-demand mode/, "ask block declares the mode");
|
|
39
|
+
assert.match(out.packBlock, /recall query/, "ask block must point at the pull command");
|
|
40
|
+
assert.match(out.packBlock, /taking a look at my notes/, "ask block must ask the model to narrate lookups");
|
|
41
|
+
const out2 = await ask.run({});
|
|
42
|
+
assert.strictEqual(out2.packBlock, out.packBlock, "ask block is constant — no per-turn matching");
|
|
43
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
|
44
|
+
|
|
16
45
|
// env override when no setting
|
|
17
46
|
const prev = process.env.RECALL_ENGINE;
|
|
18
47
|
process.env.RECALL_ENGINE = "classic";
|
|
@@ -22,6 +22,14 @@ assert.match(lifecycle, /killTree\(proc\?\.pid,\s*"SIGKILL"\)/, 'watchdog must f
|
|
|
22
22
|
assert.match(lifecycle, /await terminationPromise/, 'terminal settlement must await forced descendant cleanup');
|
|
23
23
|
assert.match(runner, /await drainQueuedMessages\(state\)/, 'watchdog path must not strand queued follow-up messages');
|
|
24
24
|
|
|
25
|
+
// Live-call hygiene: status chatter must never be spoken down the line. Each
|
|
26
|
+
// in-turn status emitter (queue ack, usage alert, memory-review + guardrail
|
|
27
|
+
// announces) must consult statusChatterMuted(), which honors the call
|
|
28
|
+
// adapter's suppressChatVisibility flag.
|
|
29
|
+
assert.match(runner, /function statusChatterMuted\(\)[\s\S]{0,200}suppressChatVisibility === true/, 'statusChatterMuted must key off adapter.suppressChatVisibility');
|
|
30
|
+
assert.match(runner, /!== "spaces" && !statusChatterMuted\(\)/, 'queue/compaction ack must stay silent on live-call turns');
|
|
31
|
+
assert.strictEqual((runner.match(/statusChatterMuted\(\) \? true : send\(/g) || []).length, 3, 'usage alert, memory-review announce, and guardrail announce must all mute on live-call turns');
|
|
32
|
+
|
|
25
33
|
assert.ok(monolith.split('\n').length <= 12, 'bot-agent.js must be only a compatibility shim');
|
|
26
34
|
assert.match(monolith, /require\(["']\.\/bot["']\)/, 'legacy entrypoint must delegate to bot.js');
|
|
27
35
|
assert.ok(!/(?:OC_TURN_IDLE_MS|CLAUDE_PATH|spawn\(|parseStream|writeFile|saveState|codex)/i.test(monolith), 'compatibility shim must contain no watchdog, provider, parser, or writer implementation');
|
|
@@ -474,7 +474,12 @@ async function main() {
|
|
|
474
474
|
},
|
|
475
475
|
})),
|
|
476
476
|
env: { UTILITY_PROVIDER: "claude" },
|
|
477
|
-
|
|
477
|
+
// Grace must outlive scheduler starvation on a loaded runner: at 400ms the
|
|
478
|
+
// SIGKILL escalation raced the child's TERM handler before it could write
|
|
479
|
+
// drain-signal.json (same class as the 3.1.16 CI deflakes). The child
|
|
480
|
+
// normally exits on TERM within milliseconds; 2000ms only bounds the
|
|
481
|
+
// emergency path and still fits inside the 5000ms cancelAll budget.
|
|
482
|
+
killGraceMs: 2000,
|
|
478
483
|
});
|
|
479
484
|
const drainingRun = drainService.spawnUtilityAgent("shutdown drain", {
|
|
480
485
|
purpose: "review", systemPrompt: "drain", cwd: root,
|