@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.
- package/CHANGELOG.md +14 -0
- package/bin/cli.js +28 -0
- package/bin/loopback-client.js +1 -0
- package/channels/kazee/call/manager.js +66 -14
- package/channels/kazee/call/session.js +154 -80
- package/channels/kazee/call/speech-client.js +96 -19
- package/channels/kazee/call/turn-gate.js +62 -0
- package/core/call-speech.js +111 -0
- package/core/loopback.js +10 -1
- package/core/providers/env.js +1 -0
- package/core/run-context.js +13 -6
- package/core/runner.js +69 -7
- package/core/system-prompt.js +19 -7
- package/package.json +4 -3
- package/test-call-speech.js +237 -0
- package/test-kazee-call-manager.js +57 -2
- package/test-kazee-call-session.js +153 -7
- package/test-kazee-call-speech.js +38 -2
- package/test-provider-env-isolation.js +5 -0
- package/test-provider-gateway.js +15 -1
- package/test-provider-language.js +3 -1
- package/test-provider-prompt-parity.js +16 -1
- package/test-provider-recall.js +46 -0
- package/test-provider-runner.js +34 -0
- package/test-tool-manifest.js +25 -12
- package/test-tools.js +11 -7
|
@@ -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
|
-
|
|
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", () => {
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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" });
|
package/core/providers/env.js
CHANGED
package/core/run-context.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|