@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 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
- if (pending && frame.janus !== "ack") {
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
- const reject = (reason) => this.adapter._socket?.emit("call:reject", {
35
- toUserId: callerId, callType, chatId, roomId, reason,
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
- const joined = await this.janus.message(this.publisherHandle, {
62
- request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
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) throw new Error(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" && !this.publisher) await this._publish();
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().catch((error) => console.error("Kazee call STT connect:", error.message));
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) throw new Error(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
- const speechIdle = !this.speaking && !this._speakActive && !this._speakQueue.length;
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 };
@@ -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. Spaces stays quiet and just posts the real reply when its
2136
- // turn runs.
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;
@@ -361,7 +361,9 @@ function buildSpeakerContextBlock() {
361
361
  : "unscoped chat";
362
362
  const blocks = [
363
363
  "## Channel context",
364
- `- Interface: ${channelLabel} chat through Open Claudia.`,
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
- const engine = recall.getEngine(recall.activeEngineName(settings));
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 || recall.activeEngineName(settings),
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}`] || "" })),
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.16",
3
+ "version": "3.1.20",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -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
- assert.strictEqual(pkg.version, "3.1.16", "release version must remain unchanged without explicit approval");
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
  }
@@ -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
- killGraceMs: 400,
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,