@inetafrica/open-claudia 3.1.17 → 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/channels/kazee/call/janus-client.js +5 -2
- package/channels/kazee/call/manager.js +13 -3
- package/channels/kazee/call/session.js +108 -3
- 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 +47 -0
- package/test-provider-language.js +25 -1
- package/test-recall-engine.js +29 -0
- package/test-runner-watchdog-static.js +8 -0
- package/test-utility-provider-policy.js +6 -1
|
@@ -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.
|
|
@@ -20,6 +26,33 @@ const DEFAULT_JOIN_RETRY_DELAY_MS = 500;
|
|
|
20
26
|
const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
|
|
21
27
|
const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
|
|
22
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
|
+
|
|
23
56
|
class KazeeCallSession {
|
|
24
57
|
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
25
58
|
maxCallMs, idleMs, synthesize = synthesizePcm,
|
|
@@ -53,6 +86,8 @@ class KazeeCallSession {
|
|
|
53
86
|
this._lastSpokeAt = 0;
|
|
54
87
|
this._limitTimer = null;
|
|
55
88
|
this._ending = false;
|
|
89
|
+
this._thinkTimer = null;
|
|
90
|
+
this._thinkingSince = 0;
|
|
56
91
|
this.sequence = Math.floor(Math.random() * 65535);
|
|
57
92
|
this.timestamp = Math.floor(Math.random() * 0xffffffff);
|
|
58
93
|
this.ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
@@ -102,7 +137,10 @@ class KazeeCallSession {
|
|
|
102
137
|
await this._subscribe(feedId, publisher.display);
|
|
103
138
|
}
|
|
104
139
|
}
|
|
105
|
-
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
|
+
}
|
|
106
144
|
const gone = Number(data.leaving || data.unpublished || 0);
|
|
107
145
|
if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
|
|
108
146
|
if (data.videoroom === "destroyed") await this.close("room-destroyed");
|
|
@@ -111,6 +149,7 @@ class KazeeCallSession {
|
|
|
111
149
|
async _publish() {
|
|
112
150
|
if (this.publisher) return;
|
|
113
151
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
152
|
+
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`));
|
|
114
153
|
const track = new MediaStreamTrack({ kind: "audio" });
|
|
115
154
|
const sender = pc.addTrack(track);
|
|
116
155
|
this.publisher = { pc, track, sender };
|
|
@@ -120,11 +159,14 @@ class KazeeCallSession {
|
|
|
120
159
|
const answer = configured?.jsep;
|
|
121
160
|
if (!answer) throw new Error("Janus publisher configure did not return SDP");
|
|
122
161
|
await pc.setRemoteDescription(answer);
|
|
162
|
+
clog(`Kazee call publisher configured: room=${this.roomId}`);
|
|
123
163
|
}
|
|
124
164
|
|
|
125
165
|
async _subscribe(feedId, display) {
|
|
166
|
+
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
126
167
|
const handle = await this.janus.attach();
|
|
127
168
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
169
|
+
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
128
170
|
const sub = {
|
|
129
171
|
handle,
|
|
130
172
|
pc,
|
|
@@ -142,7 +184,10 @@ class KazeeCallSession {
|
|
|
142
184
|
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
143
185
|
const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
|
|
144
186
|
await this._consumeSubscriberFrame(feedId, attached);
|
|
145
|
-
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
|
+
);
|
|
146
191
|
}
|
|
147
192
|
|
|
148
193
|
async _onSubscriberEvent(feedId, frame) {
|
|
@@ -165,11 +210,18 @@ class KazeeCallSession {
|
|
|
165
210
|
const answer = await sub.pc.createAnswer();
|
|
166
211
|
await sub.pc.setLocalDescription(answer);
|
|
167
212
|
await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
|
|
213
|
+
clog(`Kazee call subscriber started: feed=${feedId}`);
|
|
168
214
|
}
|
|
169
215
|
}
|
|
170
216
|
|
|
171
217
|
_onFeedTranscript(feedId, text) {
|
|
172
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();
|
|
173
225
|
this.onTranscript(text, {
|
|
174
226
|
feedId,
|
|
175
227
|
display: sub?.display || "",
|
|
@@ -177,10 +229,49 @@ class KazeeCallSession {
|
|
|
177
229
|
});
|
|
178
230
|
}
|
|
179
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
|
+
|
|
180
267
|
_onFeedRtp(feedId, packet) {
|
|
181
268
|
if (this.closed || !packet?.payload?.length) return;
|
|
182
269
|
const sub = this.subscribers.get(feedId);
|
|
183
270
|
if (!sub) return;
|
|
271
|
+
if (!sub.firstRtpAt) {
|
|
272
|
+
sub.firstRtpAt = Date.now();
|
|
273
|
+
clog(`Kazee call first RTP: feed=${feedId}`);
|
|
274
|
+
}
|
|
184
275
|
let pcm;
|
|
185
276
|
try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
|
|
186
277
|
sub.vad.thresholdDb = this.speaking ? SPEAKING_THRESHOLD_DB : BASE_THRESHOLD_DB;
|
|
@@ -193,14 +284,21 @@ class KazeeCallSession {
|
|
|
193
284
|
}
|
|
194
285
|
if (event?.type === "start") {
|
|
195
286
|
this._lastHeard = Date.now();
|
|
287
|
+
clog(`Kazee call VAD start: feed=${feedId}`);
|
|
196
288
|
// Barge-in: the human wins — stop talking and listen.
|
|
197
289
|
if (this.speaking) this.cancelSpeech();
|
|
290
|
+
this._stopThinking();
|
|
198
291
|
for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
|
|
199
292
|
}
|
|
200
293
|
this._feedAudio(sub, pcm16);
|
|
201
294
|
if (event?.type === "commit") {
|
|
202
295
|
this._lastHeard = Date.now();
|
|
296
|
+
clog(`Kazee call VAD commit: feed=${feedId}`);
|
|
203
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();
|
|
204
302
|
}
|
|
205
303
|
}
|
|
206
304
|
|
|
@@ -241,9 +339,12 @@ class KazeeCallSession {
|
|
|
241
339
|
}
|
|
242
340
|
|
|
243
341
|
async _playUtterance(text, gen) {
|
|
342
|
+
const synthStart = Date.now();
|
|
244
343
|
const synthesized = await this.synthesize(text);
|
|
245
344
|
if (this.closed || gen !== this._speakGen) return false;
|
|
246
345
|
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
346
|
+
clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
347
|
+
this._stopThinking();
|
|
247
348
|
this.speaking = true;
|
|
248
349
|
try {
|
|
249
350
|
for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
|
|
@@ -268,7 +369,9 @@ class KazeeCallSession {
|
|
|
268
369
|
if (this.closed || this._ending) return;
|
|
269
370
|
const now = Date.now();
|
|
270
371
|
const overMax = now - this._startedAt >= this.maxCallMs;
|
|
271
|
-
|
|
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;
|
|
272
375
|
const idle = speechIdle && now - Math.max(this._lastHeard, this._lastSpokeAt) >= this.idleMs;
|
|
273
376
|
if (!overMax && !idle) return;
|
|
274
377
|
this._ending = true;
|
|
@@ -295,7 +398,9 @@ class KazeeCallSession {
|
|
|
295
398
|
async close(reason = "ended") {
|
|
296
399
|
if (this.closed) return;
|
|
297
400
|
this.closed = true;
|
|
401
|
+
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
298
402
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
403
|
+
this._stopThinking();
|
|
299
404
|
this.cancelSpeech();
|
|
300
405
|
for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
|
|
301
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 });
|
|
@@ -228,6 +228,52 @@ async function testJoinGivesUpAfterRetries() {
|
|
|
228
228
|
await session.close();
|
|
229
229
|
}
|
|
230
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
|
+
|
|
231
277
|
async function testCloseResolvesSpeech() {
|
|
232
278
|
const { session } = makeSession({
|
|
233
279
|
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
|
|
@@ -254,6 +300,7 @@ async function testCloseResolvesSpeech() {
|
|
|
254
300
|
await testMaxDuration();
|
|
255
301
|
await testJoinRetriesNoSuchRoom();
|
|
256
302
|
await testJoinGivesUpAfterRetries();
|
|
303
|
+
await testThinkingCues();
|
|
257
304
|
await testCloseResolvesSpeech();
|
|
258
305
|
console.log("kazee call session: ok");
|
|
259
306
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -227,7 +227,31 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
227
227
|
// the Janus publisher join through transient 426 "No such room" — the caller
|
|
228
228
|
// creates the room at dial time and the bot answers faster than any human, so
|
|
229
229
|
// the single naked join lost the race deterministically.
|
|
230
|
-
|
|
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");
|
|
231
255
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
232
256
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
233
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,
|