@inetafrica/open-claudia 3.5.47 → 3.5.59
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 +70 -10
- package/bin/cli.js +10 -3
- package/bot.js +11 -0
- package/channels/kazee/adapter.js +244 -23
- package/channels/kazee/call/camera.js +233 -0
- package/channels/kazee/call/job-steps.js +81 -0
- package/channels/kazee/call/manager.js +1056 -27
- package/channels/kazee/call/miss-capture.js +82 -0
- package/channels/kazee/call/quick-voice.js +610 -0
- package/channels/kazee/call/session.js +787 -36
- package/channels/kazee/call/thread-context.js +100 -0
- package/channels/kazee/catch-up.js +275 -0
- package/core/actions.js +13 -4
- package/core/enforcer.js +3 -0
- package/core/handlers.js +14 -16
- package/core/identity.js +15 -1
- package/core/loopback.js +4 -1
- package/core/media.js +39 -1
- package/core/relationship.js +41 -18
- package/core/run-context.js +27 -3
- package/core/runner.js +70 -1
- package/core/state.js +6 -2
- package/core/switchboard/brain-link.js +161 -0
- package/core/switchboard/call-host.js +298 -0
- package/core/switchboard/handoff.js +80 -0
- package/core/switchboard/ipc.js +130 -0
- package/core/switchboard/supervisor.js +225 -0
- package/core/system-prompt.js +64 -12
- package/core/transcripts.js +39 -0
- package/core/tts-voice.js +62 -4
- package/core/turn-journal.js +23 -1
- package/core/utility-agent.js +5 -2
- package/package.json +2 -2
- package/test-call-speech.js +7 -0
- package/test-external-rule-scopes.js +24 -0
- package/test-fixtures/switchboard-fake-brain.js +20 -0
- package/test-kazee-call-manager.js +22 -4
- package/test-kazee-call-session.js +345 -8
- package/test-provider-compaction.js +8 -0
- package/test-provider-language.js +19 -9
- package/test-provider-prompt-parity.js +1 -0
- package/test-provider-runner.js +2 -0
- package/test-run-lock.js +24 -0
|
@@ -0,0 +1,610 @@
|
|
|
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 a
|
|
6
|
+
// question itself that needs the full agent's tools: that agent's call turn
|
|
7
|
+
// does the work, and the quick voice says its answer in its own words
|
|
8
|
+
// (answerMessage; 11 Oct 17:47, the caller: "it should handle everything"),
|
|
9
|
+
// so the call has one voice that knows what it already said. One process per
|
|
10
|
+
// call, spawned through the provider's own utility invocation (binary, env and
|
|
11
|
+
// credentials come from the provider, never from here). Ported from the 5 Oct
|
|
12
|
+
// front-voice test build.
|
|
13
|
+
|
|
14
|
+
const { spawn } = require("child_process");
|
|
15
|
+
const { StringDecoder } = require("string_decoder");
|
|
16
|
+
const { killProcessTree } = require("../../../core/process-tree");
|
|
17
|
+
const { redactSensitive } = require("../../../core/redact");
|
|
18
|
+
|
|
19
|
+
const KILL_GRACE_MS = 2000;
|
|
20
|
+
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
21
|
+
const MAX_LINE_CHARS = 120;
|
|
22
|
+
// Claudia's words passed to the quick voice, and its spoken reply for them.
|
|
23
|
+
const RELAY_INPUT_CHARS = 1500;
|
|
24
|
+
const RELAY_OUTPUT_CHARS = 1500;
|
|
25
|
+
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
26
|
+
|
|
27
|
+
// The bot's .env is parsed into the config object, not process.env: read the
|
|
28
|
+
// config first, the environment second.
|
|
29
|
+
function setting(name) {
|
|
30
|
+
let value = process.env[name];
|
|
31
|
+
try { value = require("../../../core/config").config?.[name] ?? value; } catch (_) {}
|
|
32
|
+
return String(value ?? "").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Measured 11 Oct, warm process, thinking off, 23 call lines with traps
|
|
36
|
+
// ("Did you hear me?", "Are you sure?"): Haiku 4.5 finished its line in
|
|
37
|
+
// 0.8-1.1 s (Sonnet 5 at low effort 1.7-1.9 s); both kept every rule once the
|
|
38
|
+
// prompt named the traps (a looser prompt let Haiku answer "you said it once").
|
|
39
|
+
// On by default since 3.5.58 (the fleet release of the call work, Sumeet
|
|
40
|
+
// 13 Oct: "can we roll it out the rest of the fleet?"), on Opus 5.5 as on the
|
|
41
|
+
// owner's own bot; KAZEE_CALL_QUICK_VOICE=off brings back the full agent alone.
|
|
42
|
+
function quickVoiceSettings(read = setting) {
|
|
43
|
+
const effort = read("KAZEE_CALL_QUICK_EFFORT").toLowerCase() || "low";
|
|
44
|
+
return {
|
|
45
|
+
enabled: !/^(?:off|0|false|no)$/i.test(read("KAZEE_CALL_QUICK_VOICE")),
|
|
46
|
+
model: read("KAZEE_CALL_QUICK_MODEL") || "claude-opus-5-5",
|
|
47
|
+
effort: EFFORTS.has(effort) ? effort : "low",
|
|
48
|
+
thinking: /^(?:on|1|true|yes)$/i.test(read("KAZEE_CALL_QUICK_THINKING")),
|
|
49
|
+
// It says the full agent's answers too (manager.js _relay); off: they
|
|
50
|
+
// play as the full agent wrote them, as before.
|
|
51
|
+
relay: !/^(?:off|0|false|no)$/i.test(read("KAZEE_CALL_QUICK_RELAY")),
|
|
52
|
+
// It starts on the words heard when the caller stopped, before the
|
|
53
|
+
// recogniser's final (manager.js _earlyWords); off: it waits for the final.
|
|
54
|
+
early: !/^(?:off|0|false|no)$/i.test(read("KAZEE_CALL_QUICK_EARLY")),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ownerDisplayName() {
|
|
59
|
+
try {
|
|
60
|
+
const name = String(require("../../../core/people").owners()?.[0]?.name || "").trim();
|
|
61
|
+
return name && name.toLowerCase() !== "owner" ? name : "";
|
|
62
|
+
} catch (_) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Caller words and spoken history only: no file paths, links, ids or secrets
|
|
68
|
+
// reach the quick voice.
|
|
69
|
+
function scrubLine(text) {
|
|
70
|
+
return redactSensitive(String(text || ""))
|
|
71
|
+
.replace(/https?:\/\/\S+/gi, "a link")
|
|
72
|
+
.replace(/(?:~|\.{1,2})?\/[\w.@%+-]+(?:\/[\w.@%+-]*)+/g, "a file")
|
|
73
|
+
.replace(/\b(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{16,}\b/g, "an id")
|
|
74
|
+
.replace(/\[REDACTED[^\]]*\]/g, "something private")
|
|
75
|
+
.replace(/[`*_#>|[\]]/g, " ")
|
|
76
|
+
.replace(/\s+/g, " ")
|
|
77
|
+
.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildQuickPrompt({ ownerName = "", relay = false } = {}) {
|
|
81
|
+
const owner = scrubLine(ownerName).slice(0, 60) || "the caller";
|
|
82
|
+
return [
|
|
83
|
+
`You are the quick voice of Claudia, ${owner}'s assistant, on a live phone call with ${owner}.`,
|
|
84
|
+
relay
|
|
85
|
+
? "You are the only voice on the call. Claudia herself (the full assistant, with tools) does the real work in the background; her answers reach you as text ([Answer]) and you say them to the caller."
|
|
86
|
+
: "You keep the conversation going. Claudia herself (the full assistant, with tools) does the real work in the background, and her answers are spoken when they are ready.",
|
|
87
|
+
"",
|
|
88
|
+
"A message about what the caller just said starts with [Caller], then [Work]: what Claudia is doing for them right now, then [Now]: their local day and time. Start your reply with ONE tag, then the spoken line on the same line.",
|
|
89
|
+
"",
|
|
90
|
+
"You answer yourself whatever you can answer from what you are given: the time or day from [Now], what Claudia is doing or already answered on this call from [Work], what you two were working on from the chat you were given at call start, and everyday general knowledge that does not depend on their business, systems, people, data or anything recent (a spelling, simple sums, a well-known fact). Never make them wait for Claudia for those. Anything about their business, systems, people, data, money, or anything recent or that you are not sure of, is Claudia's.",
|
|
91
|
+
"",
|
|
92
|
+
"When [Work] says Claudia is not working on anything:",
|
|
93
|
+
"- [[SELF]] when your line fully handles it and Claudia is not needed: a greeting, \"Are you there?\", \"Can you hear me?\", thanks, okay, small talk, what you two were working on (only from [Work] or the chat you were given at call start), or a question you answer yourself (see above), like \"What time is it?\".",
|
|
94
|
+
"- A [[SELF]] line answers first, plainly. Then it keeps things moving: when a next step follows naturally, end with it in a few words, an offer or a suggestion like \"Want me to set that up?\" or \"Does that answer it?\"; otherwise a short \"Anything else on that?\" or \"What shall we look at next?\". Never change the subject just to fill a gap, vary it, and never sound like you are ending the call.",
|
|
95
|
+
"- [[IGNORE]] and no line when it was plainly not meant for you: they were talking to someone else (another person's name, \"hang on\" to someone nearby), or it is a TV, radio, video or other people talking in the background. If your last words were cut off, they are said again after. If it could be for you, it is.",
|
|
96
|
+
"- [[BYE]] when they want the call to end (\"Bye\", \"Hang up\", \"I have to go\", \"No, hang up\"): a goodbye of 2 to 8 words, like \"Okay, talk soon. Bye.\" The call ends right after your line. Never say you are hanging up without this tag.",
|
|
97
|
+
"- When they want something done and then the call ended (\"Do that, then hang up and call me back\"), start with [[JOB]] [[BYE]]: the call ends right after your line, and Claudia does it and reports in the chat. Say so in 4 to 10 words, like \"Sure, I'll do that and message you. Bye.\"",
|
|
98
|
+
"- [[JOB]] for everything else: any other question, fact, check, lookup or action. Your line only shows you heard (see Holding lines).",
|
|
99
|
+
"- Anything they want done, changed or tried is [[JOB]], however small: \"Change your voice\", \"Try saying something\", \"Send me that\". You cannot do anything yourself; only Claudia can.",
|
|
100
|
+
"- Anything about what can be seen on the call (\"Can you see me?\", \"What am I holding?\", \"How do I look?\") is [[JOB]]: only Claudia can look at their camera.",
|
|
101
|
+
"- \"What's going on?\", \"You're taking long\", \"Did you do it?\" or \"I'm still waiting\" while Claudia is not working on anything: [[JOB]], so she can find what was missed.",
|
|
102
|
+
"When unsure, [[JOB]].",
|
|
103
|
+
"",
|
|
104
|
+
"When [Work] says Claudia is working on something:",
|
|
105
|
+
"- [[IGNORE]] and no line: they were talking to someone else, or it was not meant for you (another person's name, \"hang on\" to someone nearby, background talk).",
|
|
106
|
+
"- [[ANSWER]] for a check-in (\"You there?\"), thanks, okay, small talk, a status question (\"What are you doing?\", \"How's it going?\"), or a question you answer yourself (see above), like \"What time is it?\": 4 to 16 words, like \"Still here, reading the call logs now.\" or \"It's quarter to two.\" Status only from [Work]; never guess how long is left or say it is nearly done. The work carries on.",
|
|
107
|
+
"- [[ADD]] for a new question or request: Claudia does it right after the current work. Say something like \"Sure, I'll check that next.\" That includes anything to do later (\"Say apple at some point\", \"Remind me after this\"), and anything that mixes a check-in or a complaint with a request.",
|
|
108
|
+
"- [[REPLACE]] when they change their mind about the current work, stop it, or correct it: \"Stop\", \"Never mind\", \"Forget that\", \"No, check the mail instead\", and corrections like \"No, that's not what I asked\", \"That wasn't my question\" or \"No, but...\" followed by what they meant. Say something like \"Okay, dropping that.\", or for a correction \"Got it, let me look at that instead.\" The current work and its unsaid answer are stopped, and what they said now runs instead.",
|
|
109
|
+
"- [[BYE]] when they want the call to end: the call ends right after your line, and Claudia finishes the work and reports in the chat. Say something like \"Sure, I'll finish that and message you. Bye.\" If they also ask for something new, [[ADD]] [[BYE]].",
|
|
110
|
+
"When unsure, [[ADD]].",
|
|
111
|
+
"",
|
|
112
|
+
"Holding lines, for [[JOB]], 2 to 8 words:",
|
|
113
|
+
"- a question: \"Good question, one sec.\", or a line naming the topic, like \"Let me look at the budget.\"",
|
|
114
|
+
"- a request: \"Sure, let me check the call logs.\", naming the thing in two or three words.",
|
|
115
|
+
"- a correction or \"that's wrong\": \"Okay, let me look again.\"",
|
|
116
|
+
"Never answer a [[JOB]] yourself, even when you think you know: \"How many times did I say hello?\" gets \"Let me check.\", never \"Twice.\"; \"Are you sure?\" gets \"Let me double-check.\".",
|
|
117
|
+
"",
|
|
118
|
+
"Always: never give facts, numbers, yes or no, opinions or apologies that [Work], [Now] or everyday general knowledge do not give you; never say anything was done, found, sent or changed unless [Work] says so in plain words. For sending, changing, booking or deleting anything, say \"Sure, one sec.\" and never that it is being done.",
|
|
119
|
+
"No lists, emoji, symbols or markdown.",
|
|
120
|
+
"",
|
|
121
|
+
"[Work] may say your last words were cut off when they started talking. Then [[REPEAT]] when they were checking in, asking you to say it again, or saying something that needs no answer (\"Are you still there?\", \"Sorry, say again?\", \"Go on\"): your line is only a lead-in of 2 to 6 words, like \"Sorry, as I was saying.\"; never say the cut words yourself, they are spoken right after your line. If they asked or said something new, tag it as usual.",
|
|
122
|
+
"",
|
|
123
|
+
"A message that starts with [Still working] means Claudia is still on it: reply with no tag, one short varied line of 3 to 10 words. If it gives a step or something she said that you have not mentioned yet, say that in plain words, like \"Reading the call logs now.\"; otherwise something like \"Still on it, nearly there.\" Never repeat an earlier line word for word.",
|
|
124
|
+
"",
|
|
125
|
+
"A message that starts with [Call start] means the caller is ringing in, and gives the end of your recent text chat with them. Reply with no tag: say the line Claudia opens the call with: 6 to 16 words, starting with \"Hey\" and their first name. Name what you were last working on together in two to five plain words, like \"Still on the calls update?\" or \"Back to the Books revert?\", then a short \"What's up?\". If the chat is unclear, just ask what's up. Only name a topic the chat shows; never say it is done, fixed or live unless the chat says so in plain words, and never give numbers, dates or file names. Never ask whether they can hear you. If it says you are calling them, open instead with why you are calling, in two to five plain words, like \"Calling to test the new greeting.\", then a short \"Got a minute?\"; take the reason from the note when it gives one.",
|
|
126
|
+
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Measured 11 Oct (Haiku 4.5, cold process, ~1.6k chars of chat): the
|
|
131
|
+
// greeting is ready ~1.8 s after the call comes in.
|
|
132
|
+
const GREETING_ENTRIES = 6;
|
|
133
|
+
const GREETING_ENTRY_CHARS = 300;
|
|
134
|
+
// A thread call (thread-context.js): more of the thread, and its last brief.
|
|
135
|
+
const THREAD_GREETING_ENTRIES = 12;
|
|
136
|
+
const THREAD_BRIEF_CHARS = 1500;
|
|
137
|
+
// Older than this, "where we left off" is stale: the fixed greeting, with no
|
|
138
|
+
// extra ring (Haiku named a 9-hour-old topic despite the prompt, 11 Oct).
|
|
139
|
+
const GREETING_RECENT_MS = 6 * 60 * 60 * 1000;
|
|
140
|
+
|
|
141
|
+
// Wakeups and watches are filed as the caller's turns; they are not things
|
|
142
|
+
// the caller said.
|
|
143
|
+
const SCHEDULED_TURN = /^\[?\s*(?:Watch triggered|Watch expired|Watch check timing out|Scheduled wakeup|Wakeup:)/i;
|
|
144
|
+
|
|
145
|
+
// The call-start note: how long ago the chat was, then its last few
|
|
146
|
+
// messages, scrubbed like every other line. Empty when there is no recent
|
|
147
|
+
// chat to go on (the fixed greeting is used then). outgoing: Claudia is
|
|
148
|
+
// ringing them, with the reason she gave (open-claudia call --about), if any.
|
|
149
|
+
// context is the chat's last entries, or for a call placed inside a thread
|
|
150
|
+
// { entries, thread: { title, brief, briefAt } } (manager.js greetingContext):
|
|
151
|
+
// the call is about that thread however old its last turn is.
|
|
152
|
+
function greetingMessage(context, now = Date.now(), { outgoing = false, reason = "" } = {}) {
|
|
153
|
+
const thread = context && !Array.isArray(context) && context.thread ? context.thread : null;
|
|
154
|
+
const entries = Array.isArray(context) ? context : (Array.isArray(context?.entries) ? context.entries : []);
|
|
155
|
+
const recent = entries
|
|
156
|
+
.filter((entry) => !(entry?.role === "user" && SCHEDULED_TURN.test(String(entry?.text || "").trim())))
|
|
157
|
+
.map((entry) => ({ who: entry?.role === "user" ? "Caller" : "Claudia", text: scrubLine(entry?.text).slice(0, GREETING_ENTRY_CHARS), at: Date.parse(entry?.timestamp || "") }))
|
|
158
|
+
.filter((entry) => entry.text)
|
|
159
|
+
.slice(-(thread ? THREAD_GREETING_ENTRIES : GREETING_ENTRIES));
|
|
160
|
+
const why = outgoing ? scrubLine(reason).slice(0, GREETING_ENTRY_CHARS).replace(/[\s.!?]+$/, "") : "";
|
|
161
|
+
if (thread) return threadGreetingMessage(thread, recent, now, { outgoing, why });
|
|
162
|
+
const last = recent.at(-1)?.at;
|
|
163
|
+
const fresh = recent.length > 0 && Number.isFinite(last) && now - last <= GREETING_RECENT_MS;
|
|
164
|
+
if (!fresh && !why) return "";
|
|
165
|
+
const opening = outgoing
|
|
166
|
+
? `[Call start] You are calling them; they are about to pick up.${why ? ` Why you are calling: ${why}.` : ""}`
|
|
167
|
+
: "[Call start] The caller is ringing in.";
|
|
168
|
+
if (!fresh) return [opening, "", "Say the greeting."].join("\n");
|
|
169
|
+
const minutes = Math.max(0, Math.round((now - last) / 60000));
|
|
170
|
+
const when = minutes < 1 ? "just now" : minutes < 120 ? `${minutes} minutes ago` : `${Math.round(minutes / 60)} hours ago`;
|
|
171
|
+
return [
|
|
172
|
+
`${opening} The last message in your chat with them was ${when}. The end of that chat, oldest first:`,
|
|
173
|
+
...recent.map((entry) => `${entry.who}: ${entry.text}`),
|
|
174
|
+
"",
|
|
175
|
+
"Say the greeting.",
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function agoText(at, now) {
|
|
180
|
+
const minutes = Math.max(0, Math.round((now - at) / 60000));
|
|
181
|
+
if (minutes < 1) return "just now";
|
|
182
|
+
if (minutes < 120) return `${minutes} minutes ago`;
|
|
183
|
+
const hours = Math.round(minutes / 60);
|
|
184
|
+
return hours < 48 ? `${hours} hours ago` : `${Math.round(hours / 24)} days ago`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The call-start note of a thread call: what the thread is, where it stood at
|
|
188
|
+
// its last brief, then its last turns. Never empty: the thread is the topic.
|
|
189
|
+
function threadGreetingMessage(thread, recent, now, { outgoing, why }) {
|
|
190
|
+
const title = scrubLine(thread.title).slice(0, 80);
|
|
191
|
+
const named = title ? ` "${title}"` : "";
|
|
192
|
+
const opening = outgoing
|
|
193
|
+
? `[Call start] You are calling them from your chat thread${named}; they are about to pick up.${why ? ` Why you are calling: ${why}.` : ""}`
|
|
194
|
+
: `[Call start] The caller is ringing in from your chat thread${named}.`;
|
|
195
|
+
const lines = [`${opening} The call is about that thread's topic: keep to it unless they change it.`];
|
|
196
|
+
const brief = scrubLine(thread.brief).slice(0, THREAD_BRIEF_CHARS);
|
|
197
|
+
if (brief) {
|
|
198
|
+
const when = Number.isFinite(Number(thread.briefAt)) && Number(thread.briefAt) > 0 ? ` (${agoText(Number(thread.briefAt), now)})` : "";
|
|
199
|
+
lines.push("", `Where the thread stood at its last summary${when}: ${brief}`);
|
|
200
|
+
}
|
|
201
|
+
const last = recent.at(-1)?.at;
|
|
202
|
+
if (recent.length) {
|
|
203
|
+
lines.push("", `The end of that thread${Number.isFinite(last) ? ` (last message ${agoText(last, now)})` : ""}, oldest first:`,
|
|
204
|
+
...recent.map((entry) => `${entry.who}: ${entry.text}`));
|
|
205
|
+
}
|
|
206
|
+
lines.push("", "Say the greeting: name the thread's topic in a few words.");
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// What the quick voice should say for one utterance, or a filler while the
|
|
211
|
+
// full agent is still working.
|
|
212
|
+
//
|
|
213
|
+
// The work note tells the quick voice what Claudia is doing for the caller,
|
|
214
|
+
// from the call's own job board (manager.js _board): the job running now, how
|
|
215
|
+
// long, its steps and what it has said so far, what waits after it, and what
|
|
216
|
+
// was answered earlier on the call. Only this lets the quick voice answer a
|
|
217
|
+
// status question or a check-in without a lookup of its own.
|
|
218
|
+
function workNote(board = {}) {
|
|
219
|
+
const quote = (text, max) => `"${scrubLine(text).slice(0, max)}"`;
|
|
220
|
+
const parts = [];
|
|
221
|
+
const current = board?.current;
|
|
222
|
+
if (current?.ready) {
|
|
223
|
+
parts.push(`Claudia has finished ${quote(current.question, 200)}; her answer is spoken right after your line, so never give it yourself.`);
|
|
224
|
+
} else if (current) {
|
|
225
|
+
parts.push(`Claudia is working on ${quote(current.question, 200)}, ${Math.round(current.seconds || 0)} seconds in.`);
|
|
226
|
+
const steps = (current.steps || []).slice(-4).map((step) => scrubLine(step).slice(0, 60)).filter(Boolean);
|
|
227
|
+
if (steps.length) parts.push(`Steps so far: ${steps.join("; ")}.`);
|
|
228
|
+
const said = (current.said || []).slice(-2).map((line) => quote(line, 200));
|
|
229
|
+
if (said.length) parts.push(`She has said so far: ${said.join(" ")}.`);
|
|
230
|
+
} else parts.push("Claudia is not working on anything.");
|
|
231
|
+
// Words cut off by the caller starting to talk (session.js cutSpeech): the
|
|
232
|
+
// quick voice may have them said again ([[REPEAT]]).
|
|
233
|
+
if (board?.cut) parts.push(`Your last words were cut off when they started talking; they did not hear: ${quote(board.cut, 200)}.`);
|
|
234
|
+
const next = (board?.next || []).slice(0, 3).map((question) => quote(question, 120));
|
|
235
|
+
if (next.length) parts.push(`Next after it: ${next.join(", ")}.`);
|
|
236
|
+
const earlier = (board?.earlier || []).slice(-2).map((item) => `asked ${quote(item.question, 120)}, she answered ${quote(item.answer, 240)}`);
|
|
237
|
+
if (earlier.length) parts.push(`Earlier on this call: ${earlier.join("; ")}.`);
|
|
238
|
+
return parts.join(" ");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The caller's local day and time, so "What time is it?" never waits for
|
|
242
|
+
// the full agent (11 Oct: the quick voice held it for 32 s). OC_TIMEZONE as
|
|
243
|
+
// in core/cards.js.
|
|
244
|
+
function nowNote(now = Date.now()) {
|
|
245
|
+
const date = new Date(now);
|
|
246
|
+
const timeZone = process.env.OC_TIMEZONE || "Africa/Nairobi";
|
|
247
|
+
try {
|
|
248
|
+
const day = date.toLocaleDateString("en-GB", { timeZone, weekday: "long", day: "numeric", month: "long" });
|
|
249
|
+
const time = date.toLocaleTimeString("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).toLowerCase();
|
|
250
|
+
return `${day}, ${time}`;
|
|
251
|
+
} catch (_) {
|
|
252
|
+
return date.toUTCString();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The same words for an early start (manager.js _earlyWords): case,
|
|
257
|
+
// punctuation and spacing aside, as the final often only adds those.
|
|
258
|
+
function sameWords(a, b) {
|
|
259
|
+
const key = (text) => String(text || "").toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
|
|
260
|
+
return key(a) === key(b);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A reply the caller never heard: it answered words the recogniser then
|
|
264
|
+
// heard differently, or that were noise. The next message says so, so the
|
|
265
|
+
// quick voice neither thinks it said it nor answers those words again.
|
|
266
|
+
const UNSAID_NOTE = "[Not said] Your last reply was never spoken: the words it answered were misheard or were noise. Treat them as unheard.";
|
|
267
|
+
|
|
268
|
+
function utteranceMessage(text, board, now = Date.now()) {
|
|
269
|
+
return `[Caller] ${scrubLine(text).slice(0, 600)}\n[Work] ${workNote(board)}\n[Now] ${nowNote(now)}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Claudia's answer to an earlier question is next, and the talk moved on
|
|
273
|
+
// since it was asked (manager.js _leadIn): a few words naming its topic. The
|
|
274
|
+
// rule rides in the message, not the system prompt: a "no tag" rule there
|
|
275
|
+
// cost the caller lines their tags (a cold "Hello?" lost [[SELF]], 11 Oct).
|
|
276
|
+
function leadInMessage(question) {
|
|
277
|
+
return `[Lead-in] Claudia's answer to "${scrubLine(question).slice(0, 200)}" is about to be spoken, after the talk moved on to something else. Reply with no tag: 2 to 6 words naming that topic, so the caller knows which question the answer is for, like "Back to the mail backup." or "On your voice question." Never give any part of the answer.`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Claudia's words for the caller, for the quick voice to say (manager.js
|
|
281
|
+
// _relay): in its own words, leaving out what the caller already heard,
|
|
282
|
+
// which its own replies on this call show. The rules ride in the message, as
|
|
283
|
+
// for lead-ins. 11 Oct 18:28: two answers explained the same thing one after
|
|
284
|
+
// the other ("you just started re-explaining the same thing").
|
|
285
|
+
function answerMessage({ question = "", answer = "", done = true, movedOn = "", quietSeconds = null } = {}) {
|
|
286
|
+
const asked = `"${scrubLine(question).slice(0, 200)}"`;
|
|
287
|
+
return [
|
|
288
|
+
`[Answer] ${done ? `Claudia's answer to ${asked}` : `Part of Claudia's answer to ${asked} (she is still working)`}: "${scrubLine(answer).slice(0, RELAY_INPUT_CHARS)}"`,
|
|
289
|
+
movedOn ? `Since it was asked, the talk moved on to "${scrubLine(movedOn).slice(0, 200)}": open with 2 to 6 words naming this answer's topic, like "Back to the mail backup."` : "",
|
|
290
|
+
Number.isFinite(quietSeconds) ? `The caller last heard you ${Math.round(quietSeconds)} seconds ago.` : "",
|
|
291
|
+
"Reply with no tag: say it to the caller yourself, in your own words, as one short spoken reply.",
|
|
292
|
+
"- Keep every fact, number, name, time and yes or no exactly as she wrote it, and add nothing she did not say.",
|
|
293
|
+
"- To the caller you and she are one assistant: her \"I\" and \"me\" stay \"I\" and \"me\". Never call her Claudia, she or her.",
|
|
294
|
+
"- Leave out anything already said on this call, by you or by her: your earlier replies show what the caller heard. Never explain the same thing twice.",
|
|
295
|
+
"- When nothing in it is new to the caller, or it only says what she is about to do (\"Checking the settings now\") and they heard you in the last 8 seconds: reply [[SKIP]] and nothing else.",
|
|
296
|
+
"- Keep her closing offer, suggestion or question (\"Want me to fix it now?\"). If she has none and she answered a direct question at length, you may end with a short \"Does that answer it?\". Never change the subject.",
|
|
297
|
+
"- Phone style: short sentences, no lists, symbols or markdown.",
|
|
298
|
+
].filter(Boolean).join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The quick voice's reply to an [Answer]: what to say, or skip (nothing new).
|
|
302
|
+
const SKIP_TAG = /^\s*\[{1,2}\s*SKIP\s*\]{1,2}/i;
|
|
303
|
+
function relayText(raw) {
|
|
304
|
+
const text = String(raw || "");
|
|
305
|
+
if (SKIP_TAG.test(text)) return { skip: true, text: "" };
|
|
306
|
+
const clean = scrubLine(text.replace(/\[\[[^\]]*\]\]/g, " ")).replace(/^["']|["']$/g, "").trim();
|
|
307
|
+
return { skip: false, text: /[A-Za-z]/.test(clean) ? clean.slice(0, RELAY_OUTPUT_CHARS) : "" };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The first sentence of an [Answer] reply still being written, once more text
|
|
311
|
+
// follows it, so it is said while the rest is written. "" until then, while a
|
|
312
|
+
// tag is still being written, and for [[SKIP]].
|
|
313
|
+
function firstSentence(raw) {
|
|
314
|
+
const text = String(raw || "");
|
|
315
|
+
if (/^\s*\[/.test(text) && !text.includes("]")) return "";
|
|
316
|
+
const { skip, text: clean } = relayText(text);
|
|
317
|
+
if (skip) return "";
|
|
318
|
+
return clean.match(/^.+?[.!?](?=\s+\S)/)?.[0] || "";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function fillerMessage(text, seconds, board) {
|
|
322
|
+
const current = board?.current;
|
|
323
|
+
const extra = current ? workNote({ current: { ...current, question: text, seconds } }).replace(/^Claudia is working on "[^"]*", \d+ seconds in\.\s*/, "") : "";
|
|
324
|
+
return `[Still working] on "${scrubLine(text).slice(0, 200)}", ${Math.round(seconds)} seconds in.${extra ? ` ${extra}` : ""}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The quick voice's decision, from the tag that opens its reply. SELF/JOB
|
|
328
|
+
// when no work is running; ANSWER/ADD/REPLACE while it is; IGNORE (not
|
|
329
|
+
// meant for Claudia) and REPEAT (cut-off words said again) either way. No tag (or
|
|
330
|
+
// a garbled one): null, and the caller of this falls back to the safe choice.
|
|
331
|
+
const QUICK_ACTIONS = new Set(["SELF", "JOB", "IGNORE", "ANSWER", "ADD", "REPLACE", "REPEAT", "BYE"]);
|
|
332
|
+
function parseQuickReply(text) {
|
|
333
|
+
const raw = String(text || "");
|
|
334
|
+
// [[SELF]], or at the very start [SELF] or a bare SELF: Haiku 5.5 wrote
|
|
335
|
+
// every tag with single brackets ("[SELF] Yes, I'm here."), 11 Oct bench.
|
|
336
|
+
// BYE may come with another tag ("[[JOB]] [[BYE]]": do it, then hang up);
|
|
337
|
+
// the other tag is the action, and bye says the call ends after it.
|
|
338
|
+
const tags = [...raw.matchAll(/\[\[\s*([A-Za-z]+)\s*\]\]/g)].map((m) => m[1].toUpperCase()).filter((tag) => QUICK_ACTIONS.has(tag));
|
|
339
|
+
const lead = tags.length ? null : raw.match(/^\s*\[?\s*(SELF|JOB|IGNORE|ANSWER|ADD|REPLACE|REPEAT|BYE)\s*\]?(?=[\s:.,-]|$)[\s:.-]*/);
|
|
340
|
+
if (lead) tags.push(lead[1].toUpperCase());
|
|
341
|
+
let rest = lead ? raw.replace(lead[0], " ") : raw;
|
|
342
|
+
const second = rest.match(/^\s*\[\s*BYE\s*\][\s:.-]*/);
|
|
343
|
+
if (second) { tags.push("BYE"); rest = rest.replace(second[0], " "); }
|
|
344
|
+
const bye = tags.includes("BYE");
|
|
345
|
+
const action = tags.find((tag) => tag !== "BYE") || (bye ? "BYE" : null);
|
|
346
|
+
const line = action === "IGNORE" ? "" : spokenLine(rest.replace(/\[\[[^\]]*\]\]/g, " "));
|
|
347
|
+
return { action, bye, line };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// A line the quick voice said it handled alone, but which promises work
|
|
351
|
+
// ("Let me grab a test clip. One sec."): only Claudia can do things, so the
|
|
352
|
+
// job runs (11 Oct: two such promises were made and their jobs dropped).
|
|
353
|
+
function promisesWork(line) {
|
|
354
|
+
return /\b(?:let me|i'll (?:check|look|grab|get|send|switch|change|find|pull|run|do|try|set)|one sec(?:ond)?|give me a (?:sec|second|moment|minute))\b/i.test(String(line || ""));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// A spoken line from model text: first line only, no control or markup,
|
|
358
|
+
// bounded. Empty means "say nothing".
|
|
359
|
+
// The lead-in before cut-off words are said again ([[REPEAT]]): the model's
|
|
360
|
+
// own line when it is a short lead, else a plain one (Haiku 4.5 sometimes
|
|
361
|
+
// says the cut words itself, and the caller would hear them twice).
|
|
362
|
+
function repeatLead(line, cut) {
|
|
363
|
+
const words = (text) => String(text || "").toLowerCase().replace(/[^a-z0-9' ]+/g, " ").split(/\s+/).filter(Boolean);
|
|
364
|
+
const lead = words(line);
|
|
365
|
+
const cutWords = words(cut);
|
|
366
|
+
const echoes = lead.some((_, i) => i + 3 <= lead.length && cutWords.join(" ").includes(lead.slice(i, i + 3).join(" ")));
|
|
367
|
+
return !lead.length || lead.length > 8 || echoes ? "Sorry, as I was saying." : line;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function spokenLine(text) {
|
|
371
|
+
const line = String(text || "").split("\n").map((part) => part.trim()).find(Boolean) || "";
|
|
372
|
+
const clean = scrubLine(line).replace(/^["']|["']$/g, "").trim();
|
|
373
|
+
if (!clean || !/[A-Za-z]/.test(clean)) return "";
|
|
374
|
+
return clean.length > MAX_LINE_CHARS ? `${clean.slice(0, MAX_LINE_CHARS - 1)}…` : clean;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
class QuickVoiceProcess {
|
|
378
|
+
constructor({ provider, settings = quickVoiceSettings(), systemPrompt, cwd = process.cwd(), spawnImpl = spawn, maxRestarts = 1, log = () => {} } = {}) {
|
|
379
|
+
this.provider = provider;
|
|
380
|
+
this.settings = settings;
|
|
381
|
+
this.systemPrompt = systemPrompt;
|
|
382
|
+
this.cwd = cwd;
|
|
383
|
+
this.spawnImpl = spawnImpl;
|
|
384
|
+
this.maxRestarts = maxRestarts;
|
|
385
|
+
this.log = log;
|
|
386
|
+
this.proc = null;
|
|
387
|
+
this.restarts = 0;
|
|
388
|
+
this.stopped = false;
|
|
389
|
+
this.queue = []; // generations awaiting their result, oldest first
|
|
390
|
+
this._seq = 0;
|
|
391
|
+
this._unsaid = false;
|
|
392
|
+
this._stopPromise = null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
static supported(provider) {
|
|
396
|
+
return provider?.id === "claude" && provider.capabilities?.persistentUtilityStream?.support === "native" &&
|
|
397
|
+
typeof provider.buildUtilityInvocation === "function";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
invocation() {
|
|
401
|
+
const { model, effort, thinking } = this.settings;
|
|
402
|
+
const invocation = this.provider.buildUtilityInvocation({
|
|
403
|
+
purpose: "call-quick-voice",
|
|
404
|
+
tier: "low",
|
|
405
|
+
model,
|
|
406
|
+
effort,
|
|
407
|
+
coreInstructions: this.systemPrompt,
|
|
408
|
+
dynamicContext: "",
|
|
409
|
+
userPrompt: "",
|
|
410
|
+
readOnly: true,
|
|
411
|
+
fresh: true,
|
|
412
|
+
});
|
|
413
|
+
const args = [...invocation.args];
|
|
414
|
+
// The slim prompt replaces the CLI's default system prompt (no tool docs
|
|
415
|
+
// to read), which is most of what makes a warm reply start in about a second.
|
|
416
|
+
const appendAt = args.indexOf("--append-system-prompt");
|
|
417
|
+
if (appendAt >= 0) args[appendAt] = "--system-prompt";
|
|
418
|
+
const printAt = args.indexOf("-p");
|
|
419
|
+
args.splice(printAt >= 0 ? printAt + 1 : 0, 0,
|
|
420
|
+
"--input-format", "stream-json", "--include-partial-messages",
|
|
421
|
+
"--tools", "", "--strict-mcp-config", "--setting-sources", "");
|
|
422
|
+
if (!args.includes("--no-session-persistence")) args.push("--no-session-persistence");
|
|
423
|
+
const env = { ...invocation.env, OC_PROVIDER: this.provider.id, OC_UTILITY_PURPOSE: "call-quick-voice" };
|
|
424
|
+
// Thinking off: Haiku 4.5 went from 2-3 s to 0.7 s to its first word.
|
|
425
|
+
if (!thinking) env.MAX_THINKING_TOKENS = "0";
|
|
426
|
+
return { binary: invocation.binary, args, env };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
start() {
|
|
430
|
+
if (this.stopped || this.proc) return this.proc;
|
|
431
|
+
const { binary, args, env } = this.invocation();
|
|
432
|
+
const proc = this.spawnImpl(binary, args, {
|
|
433
|
+
cwd: this.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", shell: false,
|
|
434
|
+
});
|
|
435
|
+
this.proc = proc;
|
|
436
|
+
this._unsaid = false;
|
|
437
|
+
const decoder = new StringDecoder("utf8");
|
|
438
|
+
let buffer = "";
|
|
439
|
+
proc.stdout.on("data", (data) => {
|
|
440
|
+
if (this.proc !== proc) return;
|
|
441
|
+
buffer += decoder.write(data);
|
|
442
|
+
if (buffer.length > MAX_BUFFER_BYTES) { buffer = ""; this._crashed(proc, "buffer"); return; }
|
|
443
|
+
let newline;
|
|
444
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
445
|
+
const line = buffer.slice(0, newline).trim();
|
|
446
|
+
buffer = buffer.slice(newline + 1);
|
|
447
|
+
if (!line) continue;
|
|
448
|
+
let event;
|
|
449
|
+
try { event = JSON.parse(line); } catch (_) { continue; }
|
|
450
|
+
this._onEvent(event);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
proc.stderr.on("data", () => {});
|
|
454
|
+
proc.stdin.on("error", () => {});
|
|
455
|
+
proc.on("error", () => this._crashed(proc, "spawn-error"));
|
|
456
|
+
proc.on("close", () => this._crashed(proc, "exited"));
|
|
457
|
+
this.log(`started pid=${proc.pid ?? "?"} model=${this.settings.model} effort=${this.settings.effort} thinking=${this.settings.thinking ? "on" : "off"}`);
|
|
458
|
+
return proc;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
ready() {
|
|
462
|
+
const proc = this.proc;
|
|
463
|
+
return !this.stopped && !!proc && proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// One user message; resolves with the reply text as soon as its text block
|
|
467
|
+
// closes (before the CLI's end-of-turn work), or "" when cancelled/failed.
|
|
468
|
+
// onText: called with the reply so far as it is written.
|
|
469
|
+
say(content, { onText = null } = {}) {
|
|
470
|
+
if (!this.ready()) return { generation: null, text: Promise.resolve("") };
|
|
471
|
+
let resolve;
|
|
472
|
+
const text = new Promise((done) => { resolve = done; });
|
|
473
|
+
const generation = { id: ++this._seq, text: "", cancelled: false, settled: false, resolve, onText, content: String(content), proc: null };
|
|
474
|
+
this.queue.push(generation);
|
|
475
|
+
if (this.queue[0] === generation) this._writeNext();
|
|
476
|
+
return { generation, text };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// One message in the CLI at a time: messages that wait in the CLI while it
|
|
480
|
+
// is busy can be merged into one turn with one result (11 Oct bench: a
|
|
481
|
+
// stopped line and the next got a single reply, so the next never
|
|
482
|
+
// resolved, and every reply after it would go to the wrong message). The
|
|
483
|
+
// next is written when the turn before it ends; one stopped before it was
|
|
484
|
+
// written is never sent.
|
|
485
|
+
_writeNext() {
|
|
486
|
+
while (this.queue.length && this.ready()) {
|
|
487
|
+
const generation = this.queue[0];
|
|
488
|
+
if (generation.proc) return;
|
|
489
|
+
if (generation.cancelled) { this.queue.shift(); generation.settled = true; continue; }
|
|
490
|
+
const body = this._unsaid ? `${UNSAID_NOTE}\n${generation.content}` : generation.content;
|
|
491
|
+
try {
|
|
492
|
+
this.proc.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: body } })}\n`);
|
|
493
|
+
generation.proc = this.proc;
|
|
494
|
+
this._unsaid = false;
|
|
495
|
+
return;
|
|
496
|
+
} catch (_) {
|
|
497
|
+
this.queue.shift();
|
|
498
|
+
generation.settled = true;
|
|
499
|
+
generation.resolve("");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// A message it was sent stays in its history even when cancelled: the next
|
|
505
|
+
// one tells it that reply was never said (UNSAID_NOTE).
|
|
506
|
+
retract(generation) {
|
|
507
|
+
if (!generation) return;
|
|
508
|
+
this.cancel(generation);
|
|
509
|
+
// Only a message the running process got: a restart starts a new history.
|
|
510
|
+
if (generation.proc && generation.proc === this.proc) this._unsaid = true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Stops the in-flight generation (Claude Code's stream-json control
|
|
514
|
+
// protocol); the CLI then closes that turn with an error result.
|
|
515
|
+
cancel(generation) {
|
|
516
|
+
if (!generation || generation.cancelled) return;
|
|
517
|
+
generation.cancelled = true;
|
|
518
|
+
generation.resolve("");
|
|
519
|
+
if (!generation.proc && !generation.settled) {
|
|
520
|
+
this.queue = this.queue.filter((item) => item !== generation);
|
|
521
|
+
generation.settled = true;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (generation.settled || this.queue[0] !== generation || !this.ready()) return;
|
|
525
|
+
try {
|
|
526
|
+
this.proc.stdin.write(`${JSON.stringify({ type: "control_request", request_id: `quick-interrupt-${generation.id}`, request: { subtype: "interrupt" } })}\n`);
|
|
527
|
+
} catch (_) {}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
_onEvent(event) {
|
|
531
|
+
const generation = this.queue[0];
|
|
532
|
+
if (!generation) return;
|
|
533
|
+
if (event.type === "stream_event") {
|
|
534
|
+
const inner = event.event || {};
|
|
535
|
+
if (inner.type === "content_block_delta" && inner.delta?.type === "text_delta" && typeof inner.delta.text === "string") {
|
|
536
|
+
generation.text += inner.delta.text;
|
|
537
|
+
if (!generation.cancelled && generation.onText) {
|
|
538
|
+
try { generation.onText(generation.text); } catch (_) {}
|
|
539
|
+
}
|
|
540
|
+
} else if (inner.type === "content_block_stop" && generation.text.trim()) {
|
|
541
|
+
generation.resolve(generation.cancelled ? "" : generation.text);
|
|
542
|
+
}
|
|
543
|
+
} else if (event.type === "assistant" && !generation.text && Array.isArray(event.message?.content)) {
|
|
544
|
+
// A CLI without partial messages: the whole reply at once.
|
|
545
|
+
generation.text = event.message.content.filter((block) => block?.type === "text").map((block) => block.text || "").join("");
|
|
546
|
+
} else if (event.type === "result") {
|
|
547
|
+
this.queue.shift();
|
|
548
|
+
generation.settled = true;
|
|
549
|
+
generation.resolve(generation.cancelled || event.is_error ? "" : generation.text);
|
|
550
|
+
this._writeNext();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
_crashed(proc, reason) {
|
|
555
|
+
if (this.proc !== proc) return;
|
|
556
|
+
this.proc = null;
|
|
557
|
+
for (const generation of this.queue.splice(0)) { generation.settled = true; generation.resolve(""); }
|
|
558
|
+
try { killProcessTree(proc.pid, "SIGKILL"); } catch (_) {}
|
|
559
|
+
if (this.stopped) return;
|
|
560
|
+
if (this.restarts >= this.maxRestarts) {
|
|
561
|
+
this.log(`gave up reason=${reason} restarts=${this.restarts}`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.restarts += 1;
|
|
565
|
+
this.log(`restart reason=${reason} restarts=${this.restarts}`);
|
|
566
|
+
try { this.start(); } catch (error) { this.log(`restart failed: ${error.message}`); }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
stop() {
|
|
570
|
+
if (this._stopPromise) return this._stopPromise;
|
|
571
|
+
this.stopped = true;
|
|
572
|
+
live.delete(this);
|
|
573
|
+
const proc = this.proc;
|
|
574
|
+
this.proc = null;
|
|
575
|
+
for (const generation of this.queue.splice(0)) { generation.settled = true; generation.cancelled = true; generation.resolve(""); }
|
|
576
|
+
if (!proc) return (this._stopPromise = Promise.resolve());
|
|
577
|
+
this._stopPromise = new Promise((resolve) => {
|
|
578
|
+
let killTimer = null;
|
|
579
|
+
const finish = () => { if (killTimer) clearTimeout(killTimer); resolve(); };
|
|
580
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return finish();
|
|
581
|
+
proc.once("close", finish);
|
|
582
|
+
try { proc.stdin.end(); } catch (_) {}
|
|
583
|
+
try { killProcessTree(proc.pid, "SIGTERM"); } catch (_) { try { proc.kill("SIGTERM"); } catch (_) {} }
|
|
584
|
+
killTimer = setTimeout(() => {
|
|
585
|
+
try { killProcessTree(proc.pid, "SIGKILL"); } catch (_) { try { proc.kill("SIGKILL"); } catch (_) {} }
|
|
586
|
+
}, KILL_GRACE_MS);
|
|
587
|
+
killTimer.unref?.();
|
|
588
|
+
});
|
|
589
|
+
return this._stopPromise;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// A bot exit never leaves a quick-voice process behind.
|
|
594
|
+
const live = new Set();
|
|
595
|
+
function track(quick) {
|
|
596
|
+
live.add(quick);
|
|
597
|
+
return quick;
|
|
598
|
+
}
|
|
599
|
+
process.on("exit", () => {
|
|
600
|
+
for (const quick of live) {
|
|
601
|
+
const pid = quick.proc?.pid;
|
|
602
|
+
if (pid) { try { killProcessTree(pid, "SIGKILL"); } catch (_) {} }
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
module.exports = {
|
|
607
|
+
QuickVoiceProcess, quickVoiceSettings, buildQuickPrompt, utteranceMessage, fillerMessage, greetingMessage, spokenLine,
|
|
608
|
+
workNote, parseQuickReply, promisesWork, QUICK_ACTIONS, leadInMessage, nowNote, repeatLead, answerMessage, relayText, firstSentence,
|
|
609
|
+
scrubLine, ownerDisplayName, track, GREETING_ENTRIES, THREAD_GREETING_ENTRIES, sameWords, UNSAID_NOTE,
|
|
610
|
+
};
|