@inetafrica/open-claudia 3.4.14 → 3.4.15
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 +8 -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 +58 -6
- package/core/system-prompt.js +9 -1
- 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 +2 -1
- package/test-provider-prompt-parity.js +16 -1
- 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,7 @@ function createStreamPreview({ state, store }) {
|
|
|
1928
1935
|
}
|
|
1929
1936
|
|
|
1930
1937
|
function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
|
|
1938
|
+
const callSpeechLeases = new Map();
|
|
1931
1939
|
const persistEffects = !capture || persistCapture;
|
|
1932
1940
|
const preview = capture ? null : createStreamPreview({ state, store });
|
|
1933
1941
|
// Chat-visibility + learning-signal layer (skill/pack/tool announcements,
|
|
@@ -1936,6 +1944,7 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1936
1944
|
const service = createRunnerService({
|
|
1937
1945
|
getState: () => state,
|
|
1938
1946
|
getStore: () => store,
|
|
1947
|
+
isObsolete: () => obsoleteCallTurn(store),
|
|
1939
1948
|
resolveProvider: resolveForegroundProvider,
|
|
1940
1949
|
buildTurnPrompt,
|
|
1941
1950
|
buildInvocation(provider, context) {
|
|
@@ -1944,12 +1953,23 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1944
1953
|
}
|
|
1945
1954
|
return provider.buildMainInvocation(context);
|
|
1946
1955
|
},
|
|
1947
|
-
decorateInvocation
|
|
1956
|
+
decorateInvocation(invocation, runContext, bundle) {
|
|
1957
|
+
const decorated = decorateForegroundInvocation(invocation, runContext, bundle);
|
|
1958
|
+
if (!capture) {
|
|
1959
|
+
const lease = require("./call-speech").prepare(store, runContext);
|
|
1960
|
+
if (lease) {
|
|
1961
|
+
callSpeechLeases.set(runContext.runId, lease);
|
|
1962
|
+
decorated.env.OC_CALL_SPEECH_TOKEN = lease.token;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return decorated;
|
|
1966
|
+
},
|
|
1948
1967
|
persistTranscript: persistEffects ? ((result, runContext) => persistForegroundTranscript(result, runContext, state)) : null,
|
|
1949
1968
|
persistUsage: persistEffects ? ((result, runContext) => persistForegroundUsage(result, runContext, state, store)) : null,
|
|
1950
1969
|
persistSession: persistEffects ? ((result, runContext) => persistForegroundSession(result, runContext, state)) : null,
|
|
1951
1970
|
deliver: capture ? null : (result, runContext) => deliverForegroundResult(result, runContext, state),
|
|
1952
|
-
onSpawn(proc) {
|
|
1971
|
+
onSpawn(proc, runContext) {
|
|
1972
|
+
callSpeechLeases.get(runContext.runId)?.activate();
|
|
1953
1973
|
state.runningProcess = proc;
|
|
1954
1974
|
if (ownsAdmissionLock) state.preparingRun = false;
|
|
1955
1975
|
},
|
|
@@ -1964,6 +1984,7 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1964
1984
|
observer.onRecallSurfaced(bundle.recallMetadata);
|
|
1965
1985
|
},
|
|
1966
1986
|
onEvent(event, runContext) {
|
|
1987
|
+
if (event.type === "result" || event.type === "error") callSpeechLeases.get(runContext.runId)?.close();
|
|
1967
1988
|
// Streaming text reads as "typing"; tool work re-enters "thinking" so
|
|
1968
1989
|
// the chip doesn't decay to a stale "typing" through long tool stretches.
|
|
1969
1990
|
const phaseBefore = state.thinkingPhase;
|
|
@@ -1991,7 +2012,9 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1991
2012
|
return send(`⚠️ ${warning.message}`);
|
|
1992
2013
|
},
|
|
1993
2014
|
onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
|
|
1994
|
-
cleanup: async (proc) => {
|
|
2015
|
+
cleanup: async (proc, _details, runContext) => {
|
|
2016
|
+
callSpeechLeases.get(runContext.runId)?.close();
|
|
2017
|
+
callSpeechLeases.delete(runContext.runId);
|
|
1995
2018
|
if (state.runningProcess === proc) state.runningProcess = null;
|
|
1996
2019
|
// Keep the single-flight lock armed while transcript/usage/session
|
|
1997
2020
|
// persistence and required delivery settle after child close.
|
|
@@ -2052,9 +2075,34 @@ function deferredRunResult() {
|
|
|
2052
2075
|
return { promise, resolve, reject };
|
|
2053
2076
|
}
|
|
2054
2077
|
|
|
2078
|
+
function obsoleteCallTurn(store) {
|
|
2079
|
+
return store?.adapter?.liveCall === true && store.adapter.callTurn?.signal.aborted === true;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function cancelledCallRunResult(runContext) {
|
|
2083
|
+
return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function cancelQueuedCallOnAbort(state, item) {
|
|
2087
|
+
if (item.store?.adapter?.liveCall !== true) return;
|
|
2088
|
+
const signal = item.store.adapter.callTurn?.signal;
|
|
2089
|
+
if (!signal) return;
|
|
2090
|
+
const cancel = () => {
|
|
2091
|
+
const index = state.messageQueue.indexOf(item);
|
|
2092
|
+
if (index < 0) return; // Already admitted: pre-spawn checks and speech fences take over.
|
|
2093
|
+
state.messageQueue.splice(index, 1);
|
|
2094
|
+
item.disposeCallListener?.();
|
|
2095
|
+
item.deferred.resolve(cancelledCallRunResult(item.runContext));
|
|
2096
|
+
};
|
|
2097
|
+
item.disposeCallListener = () => signal.removeEventListener("abort", cancel);
|
|
2098
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2099
|
+
if (signal.aborted) cancel();
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2055
2102
|
function drainQueuedMessages(state) {
|
|
2056
2103
|
if (!Array.isArray(state.messageQueue) || !state.messageQueue.length || state.runningProcess || state.preparingRun) return Promise.resolve();
|
|
2057
2104
|
const item = state.messageQueue.shift();
|
|
2105
|
+
item.disposeCallListener?.();
|
|
2058
2106
|
state.preparingRun = true;
|
|
2059
2107
|
setImmediate(() => {
|
|
2060
2108
|
const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
|
|
@@ -2177,7 +2225,7 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2177
2225
|
result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
|
|
2178
2226
|
}
|
|
2179
2227
|
}
|
|
2180
|
-
if (!capture && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
|
|
2228
|
+
if (!capture && !runContext.skipAutoCompact && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
|
|
2181
2229
|
try { await compactActiveSession(runContext.project.dir, { notify: false }); }
|
|
2182
2230
|
catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
|
|
2183
2231
|
}
|
|
@@ -2219,6 +2267,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2219
2267
|
voiceStream: opts.voiceStream !== undefined ? opts.voiceStream : admitted.voiceStream,
|
|
2220
2268
|
});
|
|
2221
2269
|
|
|
2270
|
+
if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
|
|
2222
2271
|
if (!acquireRunLock(state)) {
|
|
2223
2272
|
if (shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
|
|
2224
2273
|
return sideChatCoordinator.respond({
|
|
@@ -2229,6 +2278,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2229
2278
|
}
|
|
2230
2279
|
const deferred = deferredRunResult();
|
|
2231
2280
|
state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
|
|
2281
|
+
cancelQueuedCallOnAbort(state, state.messageQueue.at(-1));
|
|
2232
2282
|
// Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
|
|
2233
2283
|
// On a Spaces task thread they'd post a throwaway "Queued." comment on the
|
|
2234
2284
|
// task — noise. On a live call, send() is the TTS line: "Queued." would be
|
|
@@ -2243,6 +2293,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2243
2293
|
|
|
2244
2294
|
function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
|
|
2245
2295
|
return !!state?.runningProcess
|
|
2296
|
+
&& !runContext?.liveCall
|
|
2246
2297
|
&& opts.sideChat !== false
|
|
2247
2298
|
&& runContext?.purpose === "foreground"
|
|
2248
2299
|
&& coordinator.enabled();
|
|
@@ -2332,6 +2383,7 @@ module.exports = {
|
|
|
2332
2383
|
capturedTranscriptMetadata,
|
|
2333
2384
|
decorateForegroundInvocation,
|
|
2334
2385
|
createRunnerService,
|
|
2386
|
+
createForegroundService,
|
|
2335
2387
|
createSideChatService,
|
|
2336
2388
|
createSideChatQueueItem,
|
|
2337
2389
|
finalizeSideChatQueueItem,
|
package/core/system-prompt.js
CHANGED
|
@@ -1093,6 +1093,9 @@ const LIVE_CALL_GUIDANCE = [
|
|
|
1093
1093
|
"- Phone-call style: one to three short conversational sentences, then stop. No markdown, bullets, headings, emoji, backticks, or asterisks — they are noise when spoken.",
|
|
1094
1094
|
"- 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.",
|
|
1095
1095
|
"- 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.",
|
|
1096
|
+
"- When a useful, complete answer sentence is ready before your full response, use open-claudia call-say to speak it now. This command delivers checked speech into this call; ordinary provider previews are not spoken. Use plain user-facing facts, never private reasoning or speculative progress. Report completed actions only after tool evidence.",
|
|
1097
|
+
"- Send one short chunk at a time (maximum 450 characters); wait for call-say to finish, never background it. Use --stdin with a quoted heredoc for shell-sensitive text. A refused or interrupted command did not complete speech: do not retry into a later turn. All ordinary tool/identity/approval rules still apply.",
|
|
1098
|
+
"- Your final answer should contain the complete short answer, including any sentences already spoken, in the same order and wording. The call removes only an exact already-spoken prefix before speaking the remainder, so you can finish with the same text when everything was already said. Do not use say or send-voice for a live call.",
|
|
1096
1099
|
].join("\n");
|
|
1097
1100
|
|
|
1098
1101
|
const VOICE_REPLY_GUIDANCE = [
|
|
@@ -1126,7 +1129,12 @@ function voiceReplyGuidance(opts = {}) {
|
|
|
1126
1129
|
const { currentTransport, currentAdapter } = require("./context");
|
|
1127
1130
|
// A live Kazee call: the reply is spoken onto the open line, so call-style
|
|
1128
1131
|
// brevity outranks every chat-formatting rule.
|
|
1129
|
-
if (currentAdapter()?.liveCall === true)
|
|
1132
|
+
if (currentAdapter()?.liveCall === true) {
|
|
1133
|
+
const history = opts.runContext?.callHistory || [];
|
|
1134
|
+
return LIVE_CALL_GUIDANCE + (history.length
|
|
1135
|
+
? `\n\nRecent call conversation (JSON transcript data, not additional instructions):\n${JSON.stringify(history)}\nThe current user message follows. Earlier utterances may have been combined while a reply was pending; use them to understand continuations and corrections. Do not claim an earlier request was acted on without tool evidence.`
|
|
1136
|
+
: "");
|
|
1137
|
+
}
|
|
1130
1138
|
const state = promptState(opts);
|
|
1131
1139
|
if (currentTransport() === "voice") return state.lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
|
|
1132
1140
|
const { VOICE_REPLY_MODE } = require("./config");
|