@inetafrica/open-claudia 3.4.14 → 3.4.16

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.
@@ -6,58 +6,104 @@ const { speechBearerToken } = require("../../../core/media");
6
6
  const ttsVoice = require("../../../core/tts-voice");
7
7
 
8
8
  class SpeechStream {
9
- constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript, onPartial } = {}) {
9
+ constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript, onPartial, onDisconnect } = {}) {
10
10
  this.baseUrl = String(baseUrl || "").replace(/\/$/, "");
11
11
  this.token = token;
12
12
  this.WebSocketImpl = WebSocketImpl;
13
13
  this.onTranscript = onTranscript || (() => {});
14
14
  this.onPartial = onPartial || (() => {});
15
+ this.onDisconnect = onDisconnect || (() => {});
15
16
  this.socket = null;
16
17
  this.ready = null;
18
+ this.protocol = 1;
19
+ this._protocolCommitted = false;
20
+ this.segmentId = 1;
21
+ this.streamId = 0;
22
+ this.closed = false;
17
23
  }
18
24
 
19
25
  async connect() {
26
+ if (this.closed) throw new Error("Speech stream is closed");
20
27
  if (this.ready) return this.ready;
21
28
  if (!this.baseUrl || !this.token) throw new Error("Hosted speech service is not configured");
22
29
  const url = this.baseUrl.replace(/^http/, "ws") + "/v1/audio/transcriptions/stream";
23
30
  this.ready = new Promise((resolve, reject) => {
24
31
  const ws = new this.WebSocketImpl(url, { headers: { Authorization: `Bearer ${this.token}` } });
25
32
  this.socket = ws;
33
+ this.segmentId = 1;
34
+ this.protocol = 1;
35
+ this._protocolCommitted = false;
36
+ const streamId = ++this.streamId;
26
37
  ws.once("open", () => {
27
- ws.send(JSON.stringify({ sample_rate: 16000, language: "en" }));
38
+ if (this.closed || this.socket !== ws) return reject(new Error("Speech stream is closed"));
39
+ ws.send(JSON.stringify({ sample_rate: 16000, language: "en", protocol: 2 }));
28
40
  resolve();
29
41
  });
30
42
  ws.once("error", reject);
31
- ws.on("message", (raw) => this._onMessage(raw));
32
- ws.on("close", () => { this.socket = null; this.ready = null; });
43
+ ws.on("message", (raw) => { if (this.socket === ws && !this.closed) this._onMessage(raw, streamId); });
44
+ ws.on("close", () => {
45
+ reject(new Error("Speech stream disconnected"));
46
+ if (this.socket !== ws) return;
47
+ this.socket = null;
48
+ this.ready = null;
49
+ this.protocol = 1;
50
+ this._protocolCommitted = false;
51
+ this.onDisconnect({ streamId });
52
+ });
33
53
  });
34
54
  return this.ready;
35
55
  }
36
56
 
37
57
  async audio(pcm16k) {
38
- await this.connect();
39
- if (this.socket?.readyState === this.WebSocketImpl.OPEN) this.socket.send(pcm16k);
58
+ const pending = this.connect();
59
+ const socket = this.socket;
60
+ await pending;
61
+ if (this.closed || this.socket !== socket) return;
62
+ if (socket?.readyState === this.WebSocketImpl.OPEN) socket.send(pcm16k);
40
63
  }
41
64
 
42
- async commit() {
43
- await this.connect();
44
- this.socket.send(JSON.stringify({ type: "commit" }));
65
+ commitTurn() {
66
+ const connected = this.connect();
67
+ const streamId = this.streamId;
68
+ const segmentId = this.segmentId++;
69
+ // The session has already selected bookkeeping for this protocol. Pin it
70
+ // for this socket before any asynchronous send or delayed ready frame.
71
+ // V2 servers accept V1 commits too, so an early turn can safely retain
72
+ // legacy behavior until reconnect; it cannot switch final-routing mid-turn.
73
+ this._protocolCommitted = true;
74
+ const protocol = this.protocol;
75
+ const pending = connected.then(() => {
76
+ if (this.closed || streamId !== this.streamId || !this.socket) throw new Error("Speech stream disconnected before commit");
77
+ this.socket.send(JSON.stringify({ type: "commit", ...(protocol === 2 ? { segment_id: segmentId } : {}) }));
78
+ });
79
+ return { streamId, segmentId, pending };
45
80
  }
46
81
 
47
- _onMessage(raw) {
82
+ commit() { return this.commitTurn().pending; }
83
+
84
+ _onMessage(raw, streamId = this.streamId) {
48
85
  let message;
49
86
  try { message = JSON.parse(raw.toString()); } catch (_) { return; }
87
+ if (!message || typeof message !== "object" || Array.isArray(message)) return;
88
+ if (message.type === "ready" && message.protocol === 2 && message.segment_id === 1) {
89
+ if (!this._protocolCommitted) this.protocol = 2;
90
+ return;
91
+ }
92
+ const meta = { streamId, segmentId: message.segment_id, protocol: this.protocol };
93
+ if (this.protocol === 2 && (!Number.isSafeInteger(meta.segmentId) || meta.segmentId < 1)) return;
50
94
  const text = String(message.text || message.transcript || "").trim();
51
- if (!text) return;
52
95
  if (message.final === true || message.type === "final" || message.type === "transcription") {
53
- Promise.resolve(this.onTranscript(text)).catch((error) => console.error("Kazee call transcript:", error.message));
54
- } else {
96
+ // Empty finals are meaningful: they close that segment without reusing
97
+ // an earlier partial. The session owns exactly-once dispatch.
98
+ Promise.resolve(this.onTranscript(text, meta)).catch((error) => console.error("Kazee call transcript:", error.message));
99
+ } else if (text && message.type === "partial") {
55
100
  // Interims drive word-based turn-end detection; they were previously discarded.
56
- Promise.resolve(this.onPartial(text)).catch((error) => console.error("Kazee call partial:", error.message));
101
+ Promise.resolve(this.onPartial(text, meta)).catch((error) => console.error("Kazee call partial:", error.message));
57
102
  }
58
103
  }
59
104
 
60
105
  close() {
106
+ this.closed = true;
61
107
  const ws = this.socket;
62
108
  this.socket = null;
63
109
  this.ready = null;
@@ -66,13 +112,13 @@ class SpeechStream {
66
112
  }
67
113
  }
68
114
 
69
- async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
115
+ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal } = {}) {
70
116
  if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
71
117
  const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
72
118
  method: "POST",
73
119
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
74
120
  body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
75
- signal: AbortSignal.timeout(60000),
121
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60000)]) : AbortSignal.timeout(60000),
76
122
  });
77
123
  if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
78
124
  const contentType = response.headers?.get?.("content-type") || "";
@@ -83,13 +129,13 @@ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speec
83
129
 
84
130
  // Same endpoint as synthesizePcm, but yields PCM chunks as they arrive so
85
131
  // playback can start on the first byte instead of after the full download.
86
- async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
132
+ async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal } = {}) {
87
133
  if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
88
134
  const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
89
135
  method: "POST",
90
136
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
91
137
  body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
92
- signal: AbortSignal.timeout(90000),
138
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(90000)]) : AbortSignal.timeout(90000),
93
139
  });
94
140
  if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
95
141
  const contentType = response.headers?.get?.("content-type") || "";
@@ -111,4 +157,35 @@ async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = sp
111
157
  return { sampleRate: rate, chunks: chunks() };
112
158
  }
113
159
 
114
- module.exports = { SpeechStream, synthesizePcm, synthesizeStream };
160
+ const CUE_PHRASES = Object.freeze({ none: "", acknowledge: "Mhm.", wait: "One moment.", working: "I'm checking that." });
161
+
162
+ async function controllerRequest(path, body, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal, binary = false } = {}) {
163
+ if (!baseUrl || !token) throw new Error("Hosted speech controller is not configured");
164
+ const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/${path}`, {
165
+ method: "POST",
166
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": binary ? "application/octet-stream" : "application/json" },
167
+ body: binary ? body : JSON.stringify(body),
168
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(1800)]) : AbortSignal.timeout(1800),
169
+ });
170
+ if (!response.ok) throw new Error("Hosted speech controller unavailable");
171
+ const result = await response.json();
172
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Invalid controller response");
173
+ return result;
174
+ }
175
+
176
+ async function detectTurn(pcm16, options = {}) {
177
+ const result = await controllerRequest("turn", pcm16, { ...options, binary: true });
178
+ if (typeof result.complete !== "boolean" || typeof result.probability !== "number" ||
179
+ !Number.isFinite(result.probability) || result.probability < 0 || result.probability > 1) throw new Error("Invalid turn decision");
180
+ return { complete: result.complete, probability: result.probability };
181
+ }
182
+
183
+ async function selectAcknowledgement(text, state = "waiting", options = {}) {
184
+ const allowed = { listening: ["none", "acknowledge"], waiting: ["none", "wait"], working: ["none", "working"] }[state];
185
+ if (!allowed) return "";
186
+ const result = await controllerRequest("acknowledgement", { text: String(text).slice(-1000), state }, options);
187
+ // Never speak remote free text. A cue is selected from our fixed dictionary.
188
+ return allowed.includes(result.choice) ? CUE_PHRASES[result.choice] : "";
189
+ }
190
+
191
+ module.exports = { SpeechStream, synthesizePcm, synthesizeStream, detectTurn, selectAcknowledgement, CUE_PHRASES };
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+
3
+ // Acoustic silence proposes a boundary; the remote audio model may confirm it.
4
+ // Keep ingesting while it decides. Resumed speech fences every old decision.
5
+ class SpeechTurnGate {
6
+ constructor({ decide, commit, holdMs = 1300, maxBytes = 256000 } = {}) {
7
+ this.decide = decide;
8
+ this.commit = commit;
9
+ this.holdMs = holdMs;
10
+ this.maxBytes = maxBytes;
11
+ this.frames = [];
12
+ this.bytes = 0;
13
+ this.pending = null;
14
+ this.closed = false;
15
+ }
16
+
17
+ audio(frame) {
18
+ if (this.closed) return;
19
+ this.frames.push(Buffer.from(frame));
20
+ this.bytes += frame.length;
21
+ while (this.bytes > this.maxBytes && this.frames.length > 1) this.bytes -= this.frames.shift().length;
22
+ }
23
+
24
+ resume() { this._cancel(); }
25
+
26
+ candidate(reason) {
27
+ if (this.closed || this.pending) return;
28
+ if (reason === "max-duration") return this._finish(reason);
29
+ const candidate = { controller: new AbortController(), timer: null };
30
+ this.pending = candidate;
31
+ // Acoustic VAD already waited 700ms. Model uncertainty/service failure
32
+ // extends that to two seconds, while resumed speech keeps the same turn.
33
+ candidate.timer = setTimeout(() => {
34
+ if (this.pending === candidate) this._finish("silence-deadline");
35
+ }, this.holdMs);
36
+ candidate.timer.unref?.();
37
+ const audio = Buffer.concat(this.frames).subarray(-this.maxBytes);
38
+ Promise.resolve().then(() => this.decide(audio, { signal: candidate.controller.signal })).then(result => {
39
+ if (this.closed || this.pending !== candidate) return;
40
+ if (result?.complete === true) this._finish("semantic-complete");
41
+ }).catch(() => {}); // The bounded acoustic fallback remains armed.
42
+ }
43
+
44
+ _finish(reason) {
45
+ this._cancel();
46
+ this.frames = [];
47
+ this.bytes = 0;
48
+ this.commit(reason);
49
+ }
50
+
51
+ _cancel() {
52
+ if (!this.pending) return;
53
+ clearTimeout(this.pending.timer);
54
+ this.pending.controller.abort();
55
+ this.pending = null;
56
+ }
57
+
58
+ reset() { this._cancel(); this.frames = []; this.bytes = 0; }
59
+ close() { this.closed = true; this.reset(); }
60
+ }
61
+
62
+ module.exports = { SpeechTurnGate };
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ // Explicit user-facing speech, never provider previews or reasoning. Leases
4
+ // are memory-only, activated at spawn and revoked when that run/turn ends.
5
+ const crypto = require("crypto");
6
+ const { runInChat } = require("./context");
7
+ const { redactSensitive, stripTerminalControls } = require("./redact");
8
+ const leases = new Map();
9
+
10
+ function failure(status, message) {
11
+ return Object.assign(new Error(message), { status });
12
+ }
13
+
14
+ function prepare(store, runContext) {
15
+ const adapter = store?.adapter;
16
+ if (!runContext.liveCall || runContext.purpose !== "foreground" ||
17
+ typeof adapter?.speakSentence !== "function" ||
18
+ typeof adapter?.isCallTurnActive !== "function" || !adapter.callTurn?.signal) return null;
19
+ const context = { ...store };
20
+ const token = crypto.randomBytes(32).toString("hex");
21
+ const origin = {
22
+ adapter: String(store.adapter.id || ""), channelId: String(store.channelId || ""),
23
+ userId: String(store.userId || ""), canonicalUserId: String(store.canonicalUserId || ""),
24
+ originRunId: String(runContext.runId || ""),
25
+ };
26
+ const requests = new Map();
27
+ const completedText = [];
28
+ let active = false, closed = false, busy = false, timer = null;
29
+ const signal = adapter.callTurn.signal;
30
+ const valid = () => active && !closed && !signal.aborted && adapter.isCallTurnActive();
31
+ const close = () => {
32
+ closed = true;
33
+ active = false;
34
+ clearTimeout(timer);
35
+ signal.removeEventListener("abort", close);
36
+ leases.delete(token);
37
+ if (busy) adapter.cancelCallSpeech?.();
38
+ };
39
+ const lease = {
40
+ token,
41
+ activate() {
42
+ if (active || closed || signal.aborted || !adapter.isCallTurnActive()) return;
43
+ if (leases.size >= 64) return;
44
+ active = true;
45
+ leases.set(token, lease);
46
+ signal.addEventListener("abort", close, { once: true });
47
+ timer = setTimeout(close, Math.min(runContext.timeoutMs || 1800000, 1800000));
48
+ timer.unref?.();
49
+ },
50
+ close,
51
+ async speak(payload) {
52
+ if (!valid() || Object.entries(origin).some(([key, value]) => String(payload[key] || "") !== value)) {
53
+ throw failure(409, "Call speech request is stale or belongs to another turn");
54
+ }
55
+ const rawText = typeof payload.text === "string" ? payload.text.trim() : "";
56
+ const id = payload.requestId;
57
+ if (!rawText || rawText.length > 450 || typeof id !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(id)) {
58
+ throw failure(400, "Provide a request ID and 1–450 characters of spoken text");
59
+ }
60
+ const text = redactSensitive(stripTerminalControls(rawText)).trim();
61
+ if (!text) throw failure(400, "Spoken text is empty after sanitizing");
62
+ const previous = requests.get(id);
63
+ if (previous) {
64
+ if (previous.text !== text) throw failure(409, "Speech request ID already used for different text");
65
+ return { ...await previous.promise, duplicate: true };
66
+ }
67
+ if (busy || requests.size >= 8) throw failure(429, "Call speech is busy or this turn reached its limit");
68
+ busy = true;
69
+ const promise = runInChat(context, async () => {
70
+ try {
71
+ let guarded;
72
+ try { guarded = require("./relationship").isCurrentSpeakerGuarded(); }
73
+ catch (_) { throw failure(403, "Unable to verify the speech recipient"); }
74
+ if (guarded) {
75
+ let result;
76
+ try { result = await require("./enforcer").guardOutboundReply([...completedText, text].join(" ")); }
77
+ catch (_) { throw failure(403, "Unable to verify the spoken reply"); }
78
+ if (!result?.allow) throw failure(403, "Spoken reply held by the reply guard");
79
+ }
80
+ if (!valid()) throw failure(409, "Call speech request was cancelled");
81
+ const spoken = await adapter.speakSentence(text);
82
+ if (!valid() || spoken !== true) throw failure(409, "Call speech was interrupted");
83
+ completedText.push(text);
84
+ return { ok: true, spoken: true, duplicate: false };
85
+ } finally { busy = false; }
86
+ });
87
+ requests.set(id, { text, promise });
88
+ return promise;
89
+ },
90
+ };
91
+ return lease;
92
+ }
93
+
94
+ async function speak(payload) {
95
+ const lease = leases.get(payload.callSpeechToken);
96
+ if (!lease) throw failure(409, "No active speech permission for this call turn");
97
+ return lease.speak(payload);
98
+ }
99
+
100
+ function unspokenSuffix(text, spoken) {
101
+ let rest = String(text).trim().replace(/\s+/g, " ");
102
+ for (const piece of spoken) {
103
+ const prefix = piece.trim().replace(/\s+/g, " ");
104
+ if (rest === prefix) return "";
105
+ if (!rest.startsWith(prefix + " ")) break;
106
+ rest = rest.slice(prefix.length).trimStart();
107
+ }
108
+ return rest;
109
+ }
110
+
111
+ module.exports = { prepare, speak, unspokenSuffix };
package/core/loopback.js CHANGED
@@ -83,7 +83,7 @@ const JSON_KINDS = new Set([
83
83
  "relay-send", "recent-fetch", "audit-tail",
84
84
  "auth-list", "auth-revoke",
85
85
  "approval-request",
86
- "say", "call", "tts", "transcribe",
86
+ "say", "call-say", "call", "tts", "transcribe",
87
87
  ]);
88
88
 
89
89
  // Owner gate for identity-sensitive kinds. The claimed canonicalUserId comes
@@ -171,6 +171,15 @@ async function handleJson(req, res, url, kind) {
171
171
  const adapter = registry.findAdapter(adapterId);
172
172
  if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
173
173
 
174
+ if (kind === "call-say") {
175
+ try {
176
+ const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
177
+ return reply(res, 200, result);
178
+ } catch (error) {
179
+ return reply(res, error.status || 503, { error: error.status ? error.message : "Call speech unavailable" });
180
+ }
181
+ }
182
+
174
183
  if (kind === "say") {
175
184
  const text = String(payload.text || "").trim();
176
185
  if (!text) return reply(res, 400, { error: "missing text" });
@@ -51,6 +51,7 @@ const CONTROL_PLANE_KEYS = Object.freeze([
51
51
  "KAZEE_BOT_TOKEN",
52
52
  "BOT_CONTROL_TOKEN",
53
53
  "OC_SEND_TOKEN",
54
+ "OC_CALL_SPEECH_TOKEN",
54
55
  "VOICE_BRIDGE_TOKEN",
55
56
  "ELEVENLABS_API_KEY",
56
57
  "SPEECH_API_KEY",
@@ -97,8 +97,12 @@ function captureRunContext({
97
97
  throw error;
98
98
  }
99
99
  const project = projectSnapshot(state, cwd, opts.project);
100
+ // A phone turn carries the mandatory core/rules, selective recall and an
101
+ // explicit bounded call transcript. Never resume or replace the long coding
102
+ // session associated with the same chat room.
103
+ const liveCall = store?.adapter?.liveCall === true;
100
104
  const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
101
- const requestedSessionId = opts.fresh
105
+ const requestedSessionId = opts.fresh || liveCall
102
106
  ? null
103
107
  : (opts.resumeSessionId || activeSessionId || null);
104
108
  // A quarantined session's provider-side context tripped a safety refusal;
@@ -141,17 +145,20 @@ function captureRunContext({
141
145
  cwd: cwd || project.dir || null,
142
146
  replyToMsgId: replyToMsgId == null ? null : String(replyToMsgId),
143
147
  userPrompt: prompt,
144
- fresh: !!opts.fresh || quarantinedResume,
145
- continueSession: quarantinedResume ? false : !!opts.continueSession,
146
- resumeSessionId: quarantinedResume ? null : (opts.resumeSessionId || null),
148
+ fresh: !!opts.fresh || liveCall || quarantinedResume,
149
+ continueSession: quarantinedResume || liveCall ? false : !!opts.continueSession,
150
+ resumeSessionId: quarantinedResume || liveCall ? null : (opts.resumeSessionId || null),
147
151
  quarantinedResume,
148
- skipAutoCompact: !!opts.skipAutoCompact,
152
+ skipAutoCompact: liveCall || !!opts.skipAutoCompact,
149
153
  timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
150
154
  maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,
151
155
  imagePaths: cloneSerializable(opts.imagePaths || opts.images || [], "image paths"),
152
156
  attachments: cloneSerializable(opts.attachments || [], "attachments"),
153
157
  lastInputWasVoice: !!state.lastInputWasVoice,
154
158
  voiceStream: !!opts.voiceStream || (!!state.lastInputWasVoice && origin.transport === "voice"),
159
+ liveCall,
160
+ callHistory: liveCall
161
+ ? cloneSerializable(store.adapter.callHistory || [], "call history") : [],
155
162
  readOnly: opts.readOnly === true,
156
163
  permissionMode: opts.permissionMode || null,
157
164
  transcriptRole: opts.transcriptRole || null,
@@ -159,7 +166,7 @@ function captureRunContext({
159
166
  toolHookSettings: opts.toolHookSettings || null,
160
167
  allowReadOnlyWithoutToolHook: opts.allowReadOnlyWithoutToolHook === true,
161
168
  requiredDelivery: opts.requiredDelivery !== false,
162
- attachSession: opts.attachSession !== false,
169
+ attachSession: !liveCall && opts.attachSession !== false,
163
170
  occurrenceId: opts.occurrenceId || null,
164
171
  scheduledJobId: opts.scheduledJobId || null,
165
172
  originRunId: opts.originRunId || null,
package/core/runner.js CHANGED
@@ -706,10 +706,10 @@ function preflightFailureResult(runContext, provider, failure) {
706
706
  };
707
707
  }
708
708
 
709
- function cancelledRunResult(runContext, provider) {
709
+ function cancelledRunResult(runContext, provider, message = "Cancelled by /stop before the provider started") {
710
710
  const result = preflightFailureResult(runContext, provider, {
711
711
  code: "RUN_CANCELLED",
712
- message: "Cancelled by /stop before the provider started",
712
+ message,
713
713
  });
714
714
  result.status = "cancelled";
715
715
  return result;
@@ -783,6 +783,9 @@ function createRunnerService(dependencies = {}) {
783
783
  dependencies,
784
784
  capture,
785
785
  );
786
+ if (!capture && dependencies.isObsolete?.()) {
787
+ return settleImmediateRunResult(cancelledCallRunResult(runContext), runContext, dependencies, capture);
788
+ }
786
789
  let provider;
787
790
  try {
788
791
  provider = resolveProvider(runContext.provider);
@@ -833,6 +836,9 @@ function createRunnerService(dependencies = {}) {
833
836
  // after the slow prompt build and before anything spawns. Persistence
834
837
  // still settles (the transcript records the cancelled turn); delivery is
835
838
  // suppressed downstream via the RUN_CANCELLED code.
839
+ if (!capture && dependencies.isObsolete?.()) {
840
+ return settleImmediateRunResult(cancelledCallRunResult(runContext), runContext, dependencies, capture);
841
+ }
836
842
  if (dependencies.shouldAbort && dependencies.shouldAbort(runContext, { capture })) {
837
843
  return settleImmediateRunResult(cancelledRunResult(runContext, provider), runContext, dependencies, capture);
838
844
  }
@@ -1766,6 +1772,7 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1766
1772
 
1767
1773
  function decorateForegroundInvocation(invocation, runContext, bundle) {
1768
1774
  const env = { ...invocation.env };
1775
+ delete env.OC_CALL_SPEECH_TOKEN;
1769
1776
  const lb = loopback.info();
1770
1777
  const compactionStage = runContext.purpose === "compact-summary" || runContext.purpose === "compact-seed";
1771
1778
  if (!compactionStage) {
@@ -1928,6 +1935,11 @@ function createStreamPreview({ state, store }) {
1928
1935
  }
1929
1936
 
1930
1937
  function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
1938
+ const callSpeechLeases = new Map();
1939
+ const callStage = (stage, runContext) => {
1940
+ if (capture || runContext?.liveCall !== true) return;
1941
+ console.log(`${new Date().toISOString()} Kazee call provider: stage=${stage} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
1942
+ };
1931
1943
  const persistEffects = !capture || persistCapture;
1932
1944
  const preview = capture ? null : createStreamPreview({ state, store });
1933
1945
  // Chat-visibility + learning-signal layer (skill/pack/tool announcements,
@@ -1936,20 +1948,36 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1936
1948
  const service = createRunnerService({
1937
1949
  getState: () => state,
1938
1950
  getStore: () => store,
1951
+ isObsolete: () => obsoleteCallTurn(store),
1939
1952
  resolveProvider: resolveForegroundProvider,
1940
- buildTurnPrompt,
1953
+ buildTurnPrompt(prompt, opts) {
1954
+ callStage("preparing", opts.runContext);
1955
+ return buildTurnPrompt(prompt, opts);
1956
+ },
1941
1957
  buildInvocation(provider, context) {
1942
1958
  if (context.purpose === "compact-seed" && typeof provider.buildCompactionInvocation === "function") {
1943
1959
  return provider.buildCompactionInvocation(context);
1944
1960
  }
1945
1961
  return provider.buildMainInvocation(context);
1946
1962
  },
1947
- decorateInvocation: decorateForegroundInvocation,
1963
+ decorateInvocation(invocation, runContext, bundle) {
1964
+ const decorated = decorateForegroundInvocation(invocation, runContext, bundle);
1965
+ if (!capture) {
1966
+ const lease = require("./call-speech").prepare(store, runContext);
1967
+ if (lease) {
1968
+ callSpeechLeases.set(runContext.runId, lease);
1969
+ decorated.env.OC_CALL_SPEECH_TOKEN = lease.token;
1970
+ }
1971
+ }
1972
+ return decorated;
1973
+ },
1948
1974
  persistTranscript: persistEffects ? ((result, runContext) => persistForegroundTranscript(result, runContext, state)) : null,
1949
1975
  persistUsage: persistEffects ? ((result, runContext) => persistForegroundUsage(result, runContext, state, store)) : null,
1950
1976
  persistSession: persistEffects ? ((result, runContext) => persistForegroundSession(result, runContext, state)) : null,
1951
1977
  deliver: capture ? null : (result, runContext) => deliverForegroundResult(result, runContext, state),
1952
- onSpawn(proc) {
1978
+ onSpawn(proc, runContext) {
1979
+ callStage("started", runContext);
1980
+ callSpeechLeases.get(runContext.runId)?.activate();
1953
1981
  state.runningProcess = proc;
1954
1982
  if (ownsAdmissionLock) state.preparingRun = false;
1955
1983
  },
@@ -1960,10 +1988,13 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1960
1988
  return true;
1961
1989
  },
1962
1990
  onPromptBundle(bundle, runContext) {
1991
+ callStage("prompt-ready", runContext);
1963
1992
  if (!observer || runContext.purpose !== "foreground") return;
1964
1993
  observer.onRecallSurfaced(bundle.recallMetadata);
1965
1994
  },
1966
1995
  onEvent(event, runContext) {
1996
+ if (event.type === "result" || event.type === "error") callStage("finished", runContext);
1997
+ if (event.type === "result" || event.type === "error") callSpeechLeases.get(runContext.runId)?.close();
1967
1998
  // Streaming text reads as "typing"; tool work re-enters "thinking" so
1968
1999
  // the chip doesn't decay to a stale "typing" through long tool stretches.
1969
2000
  const phaseBefore = state.thinkingPhase;
@@ -1991,7 +2022,9 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1991
2022
  return send(`⚠️ ${warning.message}`);
1992
2023
  },
1993
2024
  onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
1994
- cleanup: async (proc) => {
2025
+ cleanup: async (proc, _details, runContext) => {
2026
+ callSpeechLeases.get(runContext.runId)?.close();
2027
+ callSpeechLeases.delete(runContext.runId);
1995
2028
  if (state.runningProcess === proc) state.runningProcess = null;
1996
2029
  // Keep the single-flight lock armed while transcript/usage/session
1997
2030
  // persistence and required delivery settle after child close.
@@ -2052,9 +2085,34 @@ function deferredRunResult() {
2052
2085
  return { promise, resolve, reject };
2053
2086
  }
2054
2087
 
2088
+ function obsoleteCallTurn(store) {
2089
+ return store?.adapter?.liveCall === true && store.adapter.callTurn?.signal.aborted === true;
2090
+ }
2091
+
2092
+ function cancelledCallRunResult(runContext) {
2093
+ return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
2094
+ }
2095
+
2096
+ function cancelQueuedCallOnAbort(state, item) {
2097
+ if (item.store?.adapter?.liveCall !== true) return;
2098
+ const signal = item.store.adapter.callTurn?.signal;
2099
+ if (!signal) return;
2100
+ const cancel = () => {
2101
+ const index = state.messageQueue.indexOf(item);
2102
+ if (index < 0) return; // Already admitted: pre-spawn checks and speech fences take over.
2103
+ state.messageQueue.splice(index, 1);
2104
+ item.disposeCallListener?.();
2105
+ item.deferred.resolve(cancelledCallRunResult(item.runContext));
2106
+ };
2107
+ item.disposeCallListener = () => signal.removeEventListener("abort", cancel);
2108
+ signal.addEventListener("abort", cancel, { once: true });
2109
+ if (signal.aborted) cancel();
2110
+ }
2111
+
2055
2112
  function drainQueuedMessages(state) {
2056
2113
  if (!Array.isArray(state.messageQueue) || !state.messageQueue.length || state.runningProcess || state.preparingRun) return Promise.resolve();
2057
2114
  const item = state.messageQueue.shift();
2115
+ item.disposeCallListener?.();
2058
2116
  state.preparingRun = true;
2059
2117
  setImmediate(() => {
2060
2118
  const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
@@ -2177,7 +2235,7 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
2177
2235
  result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
2178
2236
  }
2179
2237
  }
2180
- if (!capture && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
2238
+ if (!capture && !runContext.skipAutoCompact && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
2181
2239
  try { await compactActiveSession(runContext.project.dir, { notify: false }); }
2182
2240
  catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2183
2241
  }
@@ -2219,6 +2277,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2219
2277
  voiceStream: opts.voiceStream !== undefined ? opts.voiceStream : admitted.voiceStream,
2220
2278
  });
2221
2279
 
2280
+ if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
2222
2281
  if (!acquireRunLock(state)) {
2223
2282
  if (shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
2224
2283
  return sideChatCoordinator.respond({
@@ -2229,6 +2288,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2229
2288
  }
2230
2289
  const deferred = deferredRunResult();
2231
2290
  state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
2291
+ cancelQueuedCallOnAbort(state, state.messageQueue.at(-1));
2232
2292
  // Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
2233
2293
  // On a Spaces task thread they'd post a throwaway "Queued." comment on the
2234
2294
  // task — noise. On a live call, send() is the TTS line: "Queued." would be
@@ -2243,6 +2303,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2243
2303
 
2244
2304
  function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
2245
2305
  return !!state?.runningProcess
2306
+ && !runContext?.liveCall
2246
2307
  && opts.sideChat !== false
2247
2308
  && runContext?.purpose === "foreground"
2248
2309
  && coordinator.enabled();
@@ -2332,6 +2393,7 @@ module.exports = {
2332
2393
  capturedTranscriptMetadata,
2333
2394
  decorateForegroundInvocation,
2334
2395
  createRunnerService,
2396
+ createForegroundService,
2335
2397
  createSideChatService,
2336
2398
  createSideChatQueueItem,
2337
2399
  finalizeSideChatQueueItem,