@inetafrica/open-claudia 3.5.27 → 3.5.28
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 +23 -6
- package/channels/kazee/call/manager.js +2 -137
- package/channels/kazee/call/session.js +2 -24
- package/core/browser-control.js +201 -17
- package/core/browser-drive.js +619 -0
- package/core/browser-lock.js +33 -2
- package/core/browser-view.js +90 -5
- package/core/cards.js +18 -0
- package/core/loopback.js +4 -1
- package/core/system-prompt.js +3 -11
- package/core/tool-guard.js +3 -0
- package/package.json +2 -2
- package/test-kazee-call-manager.js +0 -5
- package/test-kazee-call-session.js +0 -3
- package/test-provider-language.js +12 -5
- package/test-provider-prompt-parity.js +0 -1
- package/web.js +4 -1
- package/channels/kazee/call/quick-voice.js +0 -295
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
// The quick voice: a small, kept-warm model that says a short, fitting line
|
|
4
|
-
// the moment the caller finishes ("Sure, let me check the call logs."), and a
|
|
5
|
-
// brief "still on it" line while the full agent works. It never answers: the
|
|
6
|
-
// full agent's call turn still runs for every utterance and speaks the real
|
|
7
|
-
// reply. One process per call, spawned through the provider's own utility
|
|
8
|
-
// invocation (binary, env and credentials come from the provider, never from
|
|
9
|
-
// here). Ported from the 5 Oct front-voice test build, reduced to this job.
|
|
10
|
-
|
|
11
|
-
const { spawn } = require("child_process");
|
|
12
|
-
const { StringDecoder } = require("string_decoder");
|
|
13
|
-
const { killProcessTree } = require("../../../core/process-tree");
|
|
14
|
-
const { redactSensitive } = require("../../../core/redact");
|
|
15
|
-
|
|
16
|
-
const KILL_GRACE_MS = 2000;
|
|
17
|
-
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
18
|
-
const MAX_LINE_CHARS = 120;
|
|
19
|
-
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
20
|
-
|
|
21
|
-
// The bot's .env is parsed into the config object, not process.env: read the
|
|
22
|
-
// config first, the environment second.
|
|
23
|
-
function setting(name) {
|
|
24
|
-
let value = process.env[name];
|
|
25
|
-
try { value = require("../../../core/config").config?.[name] ?? value; } catch (_) {}
|
|
26
|
-
return String(value ?? "").trim();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Measured 11 Oct, warm process, thinking off, 23 call lines with traps
|
|
30
|
-
// ("Did you hear me?", "Are you sure?"): Haiku 4.5 finished its line in
|
|
31
|
-
// 0.8-1.1 s (Sonnet 5 at low effort 1.7-1.9 s); both kept every rule once the
|
|
32
|
-
// prompt named the traps (a looser prompt let Haiku answer "you said it once").
|
|
33
|
-
function quickVoiceSettings() {
|
|
34
|
-
const effort = setting("KAZEE_CALL_QUICK_EFFORT").toLowerCase() || "low";
|
|
35
|
-
return {
|
|
36
|
-
enabled: /^(?:on|1|true|yes)$/i.test(setting("KAZEE_CALL_QUICK_VOICE")),
|
|
37
|
-
model: setting("KAZEE_CALL_QUICK_MODEL") || "claude-haiku-4-5",
|
|
38
|
-
effort: EFFORTS.has(effort) ? effort : "low",
|
|
39
|
-
thinking: /^(?:on|1|true|yes)$/i.test(setting("KAZEE_CALL_QUICK_THINKING")),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function ownerDisplayName() {
|
|
44
|
-
try {
|
|
45
|
-
const name = String(require("../../../core/people").owners()?.[0]?.name || "").trim();
|
|
46
|
-
return name && name.toLowerCase() !== "owner" ? name : "";
|
|
47
|
-
} catch (_) {
|
|
48
|
-
return "";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Caller words and spoken history only: no file paths, links, ids or secrets
|
|
53
|
-
// reach the quick voice.
|
|
54
|
-
function scrubLine(text) {
|
|
55
|
-
return redactSensitive(String(text || ""))
|
|
56
|
-
.replace(/https?:\/\/\S+/gi, "a link")
|
|
57
|
-
.replace(/(?:~|\.{1,2})?\/[\w.@%+-]+(?:\/[\w.@%+-]*)+/g, "a file")
|
|
58
|
-
.replace(/\b(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{16,}\b/g, "an id")
|
|
59
|
-
.replace(/\[REDACTED[^\]]*\]/g, "something private")
|
|
60
|
-
.replace(/[`*_#>|[\]]/g, " ")
|
|
61
|
-
.replace(/\s+/g, " ")
|
|
62
|
-
.trim();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function buildQuickPrompt({ ownerName = "" } = {}) {
|
|
66
|
-
const owner = scrubLine(ownerName).slice(0, 60) || "the caller";
|
|
67
|
-
return [
|
|
68
|
-
`You are the quick voice of Claudia, ${owner}'s assistant, on a live phone call with ${owner}.`,
|
|
69
|
-
"Claudia herself (the full assistant, with tools) answers every request a few seconds after you. Your only job is to fill the first moment, so the caller knows they were heard.",
|
|
70
|
-
"",
|
|
71
|
-
"Reply with ONE short spoken line, 2 to 8 words, that fits what was just said:",
|
|
72
|
-
"- a greeting or a check like \"Hello?\" or \"Are you there?\": a warm, brief reply, like \"Yep, I'm here.\"",
|
|
73
|
-
"- a question: \"Good question, one sec.\", or a line naming the topic, like \"Let me look at the budget.\"",
|
|
74
|
-
"- a request: \"Sure, let me check the call logs.\", naming the thing in two or three words.",
|
|
75
|
-
"- thanks or small talk: a short natural reply.",
|
|
76
|
-
"- a correction or \"that's wrong\": \"Okay, let me look again.\"",
|
|
77
|
-
"Never answer, even when you think you know: \"Did you hear me?\" gets \"Let me check.\", never \"Yes, once.\"; \"Are you sure?\" gets \"Let me double-check.\".",
|
|
78
|
-
"Never give facts, numbers, yes or no, opinions or apologies; never say anything was done, found, sent or changed. For sending, changing, booking or deleting anything, say \"Sure, one sec.\" and never that it is being done.",
|
|
79
|
-
"No lists, emoji, symbols or markdown.",
|
|
80
|
-
"",
|
|
81
|
-
"A message that starts with [Still working] means Claudia is still on it: say a short, varied line of 3 to 7 words, like \"Still on it, nearly there.\" Never repeat an earlier line word for word.",
|
|
82
|
-
].join("\n");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// What the quick voice should say for one utterance, or a filler while the
|
|
86
|
-
// full agent is still working.
|
|
87
|
-
function utteranceMessage(text) {
|
|
88
|
-
return scrubLine(text).slice(0, 600);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function fillerMessage(text, seconds) {
|
|
92
|
-
return `[Still working] on "${scrubLine(text).slice(0, 200)}", ${Math.round(seconds)} seconds in.`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// A spoken line from model text: first line only, no control or markup,
|
|
96
|
-
// bounded. Empty means "say nothing".
|
|
97
|
-
function spokenLine(text) {
|
|
98
|
-
const line = String(text || "").split("\n").map((part) => part.trim()).find(Boolean) || "";
|
|
99
|
-
const clean = scrubLine(line).replace(/^["']|["']$/g, "").trim();
|
|
100
|
-
if (!clean || !/[A-Za-z]/.test(clean)) return "";
|
|
101
|
-
return clean.length > MAX_LINE_CHARS ? `${clean.slice(0, MAX_LINE_CHARS - 1)}…` : clean;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
class QuickVoiceProcess {
|
|
105
|
-
constructor({ provider, settings = quickVoiceSettings(), systemPrompt, cwd = process.cwd(), spawnImpl = spawn, maxRestarts = 1, log = () => {} } = {}) {
|
|
106
|
-
this.provider = provider;
|
|
107
|
-
this.settings = settings;
|
|
108
|
-
this.systemPrompt = systemPrompt;
|
|
109
|
-
this.cwd = cwd;
|
|
110
|
-
this.spawnImpl = spawnImpl;
|
|
111
|
-
this.maxRestarts = maxRestarts;
|
|
112
|
-
this.log = log;
|
|
113
|
-
this.proc = null;
|
|
114
|
-
this.restarts = 0;
|
|
115
|
-
this.stopped = false;
|
|
116
|
-
this.queue = []; // generations awaiting their result, oldest first
|
|
117
|
-
this._seq = 0;
|
|
118
|
-
this._stopPromise = null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
static supported(provider) {
|
|
122
|
-
return provider?.id === "claude" && provider.capabilities?.persistentUtilityStream?.support === "native" &&
|
|
123
|
-
typeof provider.buildUtilityInvocation === "function";
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
invocation() {
|
|
127
|
-
const { model, effort, thinking } = this.settings;
|
|
128
|
-
const invocation = this.provider.buildUtilityInvocation({
|
|
129
|
-
purpose: "call-quick-voice",
|
|
130
|
-
tier: "low",
|
|
131
|
-
model,
|
|
132
|
-
effort,
|
|
133
|
-
coreInstructions: this.systemPrompt,
|
|
134
|
-
dynamicContext: "",
|
|
135
|
-
userPrompt: "",
|
|
136
|
-
readOnly: true,
|
|
137
|
-
fresh: true,
|
|
138
|
-
});
|
|
139
|
-
const args = [...invocation.args];
|
|
140
|
-
// The slim prompt replaces the CLI's default system prompt (no tool docs
|
|
141
|
-
// to read), which is most of what makes a warm reply start in about a second.
|
|
142
|
-
const appendAt = args.indexOf("--append-system-prompt");
|
|
143
|
-
if (appendAt >= 0) args[appendAt] = "--system-prompt";
|
|
144
|
-
const printAt = args.indexOf("-p");
|
|
145
|
-
args.splice(printAt >= 0 ? printAt + 1 : 0, 0,
|
|
146
|
-
"--input-format", "stream-json", "--include-partial-messages",
|
|
147
|
-
"--tools", "", "--strict-mcp-config", "--setting-sources", "");
|
|
148
|
-
if (!args.includes("--no-session-persistence")) args.push("--no-session-persistence");
|
|
149
|
-
const env = { ...invocation.env, OC_PROVIDER: this.provider.id, OC_UTILITY_PURPOSE: "call-quick-voice" };
|
|
150
|
-
// Thinking off: Haiku 4.5 went from 2-3 s to 0.7 s to its first word.
|
|
151
|
-
if (!thinking) env.MAX_THINKING_TOKENS = "0";
|
|
152
|
-
return { binary: invocation.binary, args, env };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
start() {
|
|
156
|
-
if (this.stopped || this.proc) return this.proc;
|
|
157
|
-
const { binary, args, env } = this.invocation();
|
|
158
|
-
const proc = this.spawnImpl(binary, args, {
|
|
159
|
-
cwd: this.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", shell: false,
|
|
160
|
-
});
|
|
161
|
-
this.proc = proc;
|
|
162
|
-
const decoder = new StringDecoder("utf8");
|
|
163
|
-
let buffer = "";
|
|
164
|
-
proc.stdout.on("data", (data) => {
|
|
165
|
-
if (this.proc !== proc) return;
|
|
166
|
-
buffer += decoder.write(data);
|
|
167
|
-
if (buffer.length > MAX_BUFFER_BYTES) { buffer = ""; this._crashed(proc, "buffer"); return; }
|
|
168
|
-
let newline;
|
|
169
|
-
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
170
|
-
const line = buffer.slice(0, newline).trim();
|
|
171
|
-
buffer = buffer.slice(newline + 1);
|
|
172
|
-
if (!line) continue;
|
|
173
|
-
let event;
|
|
174
|
-
try { event = JSON.parse(line); } catch (_) { continue; }
|
|
175
|
-
this._onEvent(event);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
proc.stderr.on("data", () => {});
|
|
179
|
-
proc.stdin.on("error", () => {});
|
|
180
|
-
proc.on("error", () => this._crashed(proc, "spawn-error"));
|
|
181
|
-
proc.on("close", () => this._crashed(proc, "exited"));
|
|
182
|
-
this.log(`started pid=${proc.pid ?? "?"} model=${this.settings.model} effort=${this.settings.effort} thinking=${this.settings.thinking ? "on" : "off"}`);
|
|
183
|
-
return proc;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
ready() {
|
|
187
|
-
const proc = this.proc;
|
|
188
|
-
return !this.stopped && !!proc && proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// One user message; resolves with the reply text as soon as its text block
|
|
192
|
-
// closes (before the CLI's end-of-turn work), or "" when cancelled/failed.
|
|
193
|
-
say(content) {
|
|
194
|
-
if (!this.ready()) return { generation: null, text: Promise.resolve("") };
|
|
195
|
-
let resolve;
|
|
196
|
-
const text = new Promise((done) => { resolve = done; });
|
|
197
|
-
const generation = { id: ++this._seq, text: "", cancelled: false, settled: false, resolve };
|
|
198
|
-
this.queue.push(generation);
|
|
199
|
-
try {
|
|
200
|
-
this.proc.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: String(content) } })}\n`);
|
|
201
|
-
} catch (_) {
|
|
202
|
-
this.queue = this.queue.filter((item) => item !== generation);
|
|
203
|
-
resolve("");
|
|
204
|
-
}
|
|
205
|
-
return { generation, text };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Stops the in-flight generation (Claude Code's stream-json control
|
|
209
|
-
// protocol); the CLI then closes that turn with an error result.
|
|
210
|
-
cancel(generation) {
|
|
211
|
-
if (!generation || generation.cancelled) return;
|
|
212
|
-
generation.cancelled = true;
|
|
213
|
-
generation.resolve("");
|
|
214
|
-
if (generation.settled || this.queue[0] !== generation || !this.ready()) return;
|
|
215
|
-
try {
|
|
216
|
-
this.proc.stdin.write(`${JSON.stringify({ type: "control_request", request_id: `quick-interrupt-${generation.id}`, request: { subtype: "interrupt" } })}\n`);
|
|
217
|
-
} catch (_) {}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
_onEvent(event) {
|
|
221
|
-
const generation = this.queue[0];
|
|
222
|
-
if (!generation) return;
|
|
223
|
-
if (event.type === "stream_event") {
|
|
224
|
-
const inner = event.event || {};
|
|
225
|
-
if (inner.type === "content_block_delta" && inner.delta?.type === "text_delta" && typeof inner.delta.text === "string") {
|
|
226
|
-
generation.text += inner.delta.text;
|
|
227
|
-
} else if (inner.type === "content_block_stop" && generation.text.trim()) {
|
|
228
|
-
generation.resolve(generation.cancelled ? "" : generation.text);
|
|
229
|
-
}
|
|
230
|
-
} else if (event.type === "assistant" && !generation.text && Array.isArray(event.message?.content)) {
|
|
231
|
-
// A CLI without partial messages: the whole reply at once.
|
|
232
|
-
generation.text = event.message.content.filter((block) => block?.type === "text").map((block) => block.text || "").join("");
|
|
233
|
-
} else if (event.type === "result") {
|
|
234
|
-
this.queue.shift();
|
|
235
|
-
generation.settled = true;
|
|
236
|
-
generation.resolve(generation.cancelled || event.is_error ? "" : generation.text);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
_crashed(proc, reason) {
|
|
241
|
-
if (this.proc !== proc) return;
|
|
242
|
-
this.proc = null;
|
|
243
|
-
for (const generation of this.queue.splice(0)) { generation.settled = true; generation.resolve(""); }
|
|
244
|
-
try { killProcessTree(proc.pid, "SIGKILL"); } catch (_) {}
|
|
245
|
-
if (this.stopped) return;
|
|
246
|
-
if (this.restarts >= this.maxRestarts) {
|
|
247
|
-
this.log(`gave up reason=${reason} restarts=${this.restarts}`);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
this.restarts += 1;
|
|
251
|
-
this.log(`restart reason=${reason} restarts=${this.restarts}`);
|
|
252
|
-
try { this.start(); } catch (error) { this.log(`restart failed: ${error.message}`); }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
stop() {
|
|
256
|
-
if (this._stopPromise) return this._stopPromise;
|
|
257
|
-
this.stopped = true;
|
|
258
|
-
live.delete(this);
|
|
259
|
-
const proc = this.proc;
|
|
260
|
-
this.proc = null;
|
|
261
|
-
for (const generation of this.queue.splice(0)) { generation.settled = true; generation.cancelled = true; generation.resolve(""); }
|
|
262
|
-
if (!proc) return (this._stopPromise = Promise.resolve());
|
|
263
|
-
this._stopPromise = new Promise((resolve) => {
|
|
264
|
-
let killTimer = null;
|
|
265
|
-
const finish = () => { if (killTimer) clearTimeout(killTimer); resolve(); };
|
|
266
|
-
if (proc.exitCode !== null || proc.signalCode !== null) return finish();
|
|
267
|
-
proc.once("close", finish);
|
|
268
|
-
try { proc.stdin.end(); } catch (_) {}
|
|
269
|
-
try { killProcessTree(proc.pid, "SIGTERM"); } catch (_) { try { proc.kill("SIGTERM"); } catch (_) {} }
|
|
270
|
-
killTimer = setTimeout(() => {
|
|
271
|
-
try { killProcessTree(proc.pid, "SIGKILL"); } catch (_) { try { proc.kill("SIGKILL"); } catch (_) {} }
|
|
272
|
-
}, KILL_GRACE_MS);
|
|
273
|
-
killTimer.unref?.();
|
|
274
|
-
});
|
|
275
|
-
return this._stopPromise;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// A bot exit never leaves a quick-voice process behind.
|
|
280
|
-
const live = new Set();
|
|
281
|
-
function track(quick) {
|
|
282
|
-
live.add(quick);
|
|
283
|
-
return quick;
|
|
284
|
-
}
|
|
285
|
-
process.on("exit", () => {
|
|
286
|
-
for (const quick of live) {
|
|
287
|
-
const pid = quick.proc?.pid;
|
|
288
|
-
if (pid) { try { killProcessTree(pid, "SIGKILL"); } catch (_) {} }
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
module.exports = {
|
|
293
|
-
QuickVoiceProcess, quickVoiceSettings, buildQuickPrompt, utteranceMessage, fillerMessage, spokenLine,
|
|
294
|
-
scrubLine, ownerDisplayName, track,
|
|
295
|
-
};
|