@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.
@@ -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.
@@ -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" && !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
+ }
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().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
+ );
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
- 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;
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 };
@@ -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.17",
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 });
@@ -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
- assert.strictEqual(pkg.version, "3.1.17", "release version must remain unchanged without explicit approval");
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
  }
@@ -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,