@ouro.bot/cli 0.1.0-alpha.891 → 0.1.0-alpha.892
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.json +6 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/senses/voice/local-audio-devices.js +52 -7
- package/dist/senses/voice/local-audio-transport.js +22 -1
- package/dist/senses/voice/twilio-phone.js +17 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.892",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Local audio joins play the agent's voice through ffmpeg instead of sox, because sox's CoreAudio output can deadlock and stay silent when the process runs at lowered priority. An outbound call is treated as the job's friend only when the dialed number is on that friend's record. Phone greetings now name the agent: they had introduced themselves as ChatGPT in half of the live test calls."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.891",
|
|
6
12
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.892</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -35,7 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.LocalAudioDevices = exports.KILL_GRACE_MS = exports.defaultProcessHost = exports.PS_ENV = exports.PLAYBACK_DEVICE_NAME = exports.CAPTURE_DEVICE_NAME = void 0;
|
|
37
37
|
exports.soxCaptureArgs = soxCaptureArgs;
|
|
38
|
-
exports.
|
|
38
|
+
exports.ffmpegPlaybackArgs = ffmpegPlaybackArgs;
|
|
39
|
+
exports.audioToolboxListArgs = audioToolboxListArgs;
|
|
40
|
+
exports.findAudioToolboxDeviceIndex = findAudioToolboxDeviceIndex;
|
|
41
|
+
exports.defaultListAudioToolboxDevices = defaultListAudioToolboxDevices;
|
|
39
42
|
exports.fileCaptureArgs = fileCaptureArgs;
|
|
40
43
|
exports.filePlaybackArgs = filePlaybackArgs;
|
|
41
44
|
exports.defaultKillGroup = defaultKillGroup;
|
|
@@ -52,6 +55,7 @@ const runtime_1 = require("../../nerves/runtime");
|
|
|
52
55
|
const local_audio_1 = require("./local-audio");
|
|
53
56
|
const local_audio_mute_1 = require("./local-audio-mute");
|
|
54
57
|
const RAW_MULAW = ["-t", "raw", "-r", "8000", "-e", "mu-law", "-b", "8", "-c", "1"];
|
|
58
|
+
const AUDIO_PROCESS_NAMES = new Set(["sox", "ffmpeg"]);
|
|
55
59
|
exports.CAPTURE_DEVICE_NAME = "BlackHole 16ch";
|
|
56
60
|
exports.PLAYBACK_DEVICE_NAME = "BlackHole 2ch";
|
|
57
61
|
/** Capture a CoreAudio device as mono 8 kHz mu-law on stdout (channels 1 and 2 mixed). */
|
|
@@ -62,9 +66,50 @@ function soxCaptureArgs(device) {
|
|
|
62
66
|
// `--buffer 8192` delayed everything the agent heard by about a second.
|
|
63
67
|
return ["-q", "--buffer", "1024", "--input-buffer", "8192", "-t", "coreaudio", device, ...RAW_MULAW, "-", "remix", "1,2"];
|
|
64
68
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Play raw mono 8 kHz mu-law from stdin to an AudioToolbox output device, chosen by index.
|
|
71
|
+
*
|
|
72
|
+
* sox's CoreAudio output can deadlock when the process runs at lowered priority (a zsh background
|
|
73
|
+
* job is niced by default, and so is a launchd Background job): its main thread holds a lock inside
|
|
74
|
+
* `AudioDeviceStart` while the device IO thread waits for that lock, so nothing is ever played.
|
|
75
|
+
* Observed live: 5 of 5 niced joins were silent with sox and 2 of 2 spoke with ffmpeg. The probe
|
|
76
|
+
* flags keep ffmpeg from buffering input before it starts playing.
|
|
77
|
+
*/
|
|
78
|
+
function ffmpegPlaybackArgs(deviceIndex) {
|
|
79
|
+
return [
|
|
80
|
+
"-hide_banner", "-loglevel", "error", "-nostdin",
|
|
81
|
+
"-probesize", "32", "-analyzeduration", "0", "-fflags", "nobuffer",
|
|
82
|
+
"-f", "mulaw", "-ar", "8000", "-ac", "1", "-i", "pipe:0",
|
|
83
|
+
"-f", "audiotoolbox", "-audio_device_index", String(deviceIndex), "-",
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
/** ffmpeg prints the AudioToolbox devices to its log; `-t 0` means nothing is played. */
|
|
87
|
+
function audioToolboxListArgs() {
|
|
88
|
+
return ["-hide_banner", "-f", "lavfi", "-i", "anullsrc", "-t", "0", "-f", "audiotoolbox", "-list_devices", "true", "-"];
|
|
89
|
+
}
|
|
90
|
+
// "[AudioToolbox @ 0x7b514003c0] [1] BlackHole 2ch, BlackHole2ch_UID"
|
|
91
|
+
const AUDIOTOOLBOX_DEVICE_LINE = /\[(\d+)\]\s+(.+),\s*[^,]*$/;
|
|
92
|
+
/** The index ffmpeg uses for a device with exactly this name, or undefined when it is not listed. */
|
|
93
|
+
function findAudioToolboxDeviceIndex(listing, deviceName) {
|
|
94
|
+
for (const line of listing.split("\n")) {
|
|
95
|
+
const match = AUDIOTOOLBOX_DEVICE_LINE.exec(line.trim());
|
|
96
|
+
if (match && match[2].trim() === deviceName)
|
|
97
|
+
return Number(match[1]);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/** Runs ffmpeg's device listing and returns its log. Rejects only when ffmpeg itself cannot run. */
|
|
102
|
+
function defaultListAudioToolboxDevices(ffmpegPath = "ffmpeg", run = (file, args, callback) => {
|
|
103
|
+
(0, child_process_1.execFile)(file, args, { encoding: "utf8", timeout: 5000 }, (error, stdout, stderr) => callback(error, stdout, stderr));
|
|
104
|
+
}) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
run(ffmpegPath, audioToolboxListArgs(), (error, _stdout, stderr) => {
|
|
107
|
+
if (error && typeof error.code !== "number")
|
|
108
|
+
reject(error);
|
|
109
|
+
else
|
|
110
|
+
resolve(stderr);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
68
113
|
}
|
|
69
114
|
/** Decode any audio file to the capture stream format (stdout). The caller paces it. */
|
|
70
115
|
function fileCaptureArgs(inputPath) {
|
|
@@ -145,7 +190,7 @@ class LocalAudioDevices {
|
|
|
145
190
|
this.stop();
|
|
146
191
|
const command = this.options.soxPath ?? "sox";
|
|
147
192
|
const capture = this.spawnTracked(command, this.options.captureArgs);
|
|
148
|
-
const playback = this.spawnTracked(command, this.options.playbackArgs);
|
|
193
|
+
const playback = this.spawnTracked(this.options.playbackCommand ?? command, this.options.playbackArgs);
|
|
149
194
|
this.writePidFile();
|
|
150
195
|
this.recordStartTimes([...this.running]);
|
|
151
196
|
this.watchParentSignals();
|
|
@@ -254,10 +299,10 @@ class LocalAudioDevices {
|
|
|
254
299
|
});
|
|
255
300
|
return killed;
|
|
256
301
|
}
|
|
257
|
-
/** Only a process that is still
|
|
302
|
+
/** Only a process that is still one of our audio tools AND started when we recorded it is ours to kill (pids get reused). */
|
|
258
303
|
isRecordedSox(pid, recordedStart) {
|
|
259
304
|
const info = this.inspectProcess(pid);
|
|
260
|
-
if (!info || path.basename(info.command)
|
|
305
|
+
if (!info || !AUDIO_PROCESS_NAMES.has(path.basename(info.command)))
|
|
261
306
|
return false;
|
|
262
307
|
return recordedStart !== "" && info.startTime === recordedStart;
|
|
263
308
|
}
|
|
@@ -137,6 +137,23 @@ class LocalAudioDeviceTransport {
|
|
|
137
137
|
async leave(reason = "left") {
|
|
138
138
|
await this.finish(reason);
|
|
139
139
|
}
|
|
140
|
+
/** Live playback goes through ffmpeg, which names devices by index: find BlackHole 2ch's index now. */
|
|
141
|
+
async resolveLivePlayback() {
|
|
142
|
+
const command = this.deps.ffmpegPath ?? "ffmpeg";
|
|
143
|
+
const list = this.deps.listAudioToolboxDevices ?? (() => (0, local_audio_devices_1.defaultListAudioToolboxDevices)(command));
|
|
144
|
+
let listing;
|
|
145
|
+
try {
|
|
146
|
+
listing = await list();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
throw new Error(`Local audio routing is not ready:\n- Live playback needs ffmpeg (${errorText(error)}): brew install ffmpeg`);
|
|
150
|
+
}
|
|
151
|
+
const index = (0, local_audio_devices_1.findAudioToolboxDeviceIndex)(listing, local_audio_devices_1.PLAYBACK_DEVICE_NAME);
|
|
152
|
+
if (index === undefined) {
|
|
153
|
+
throw new Error(`Local audio routing is not ready:\n- ffmpeg does not list the playback device "${local_audio_devices_1.PLAYBACK_DEVICE_NAME}". Install it with brew install --cask blackhole-2ch, then restart macOS audio or reboot.`);
|
|
154
|
+
}
|
|
155
|
+
return { command, args: (0, local_audio_devices_1.ffmpegPlaybackArgs)(index) };
|
|
156
|
+
}
|
|
140
157
|
async startUnchecked() {
|
|
141
158
|
const { request, deps } = this;
|
|
142
159
|
if (!deps.bridgeOptions.openaiRealtime?.apiKey?.trim()) {
|
|
@@ -144,10 +161,14 @@ class LocalAudioDeviceTransport {
|
|
|
144
161
|
}
|
|
145
162
|
const files = request.files;
|
|
146
163
|
const soxPath = deps.soxPath;
|
|
164
|
+
const livePlayback = files ? undefined : await this.resolveLivePlayback();
|
|
165
|
+
if (this.finished)
|
|
166
|
+
return;
|
|
147
167
|
this.devices = new local_audio_devices_1.LocalAudioDevices({
|
|
148
168
|
spawner: deps.spawner,
|
|
149
169
|
captureArgs: files ? (0, local_audio_devices_1.fileCaptureArgs)(files.inputPath) : (0, local_audio_devices_1.soxCaptureArgs)(local_audio_devices_1.CAPTURE_DEVICE_NAME),
|
|
150
|
-
playbackArgs:
|
|
170
|
+
playbackArgs: livePlayback ? livePlayback.args : (0, local_audio_devices_1.filePlaybackArgs)(files.outputPath),
|
|
171
|
+
...(livePlayback ? { playbackCommand: livePlayback.command } : {}),
|
|
151
172
|
pidFile: deps.pidFile,
|
|
152
173
|
soxPath,
|
|
153
174
|
killGroup: deps.killGroup,
|
|
@@ -417,13 +417,19 @@ function isSafeVoiceFriendId(id) {
|
|
|
417
417
|
* (the store's display-name fallback is never used for voice). Everything else resolves from the
|
|
418
418
|
* phone number, or from the call itself when there is none.
|
|
419
419
|
*/
|
|
420
|
+
function friendHasPhone(friend, phone) {
|
|
421
|
+
return friend.externalIds.some((externalId) => (0, phone_1.normalizeTwilioE164PhoneNumber)(externalId.externalId) === phone);
|
|
422
|
+
}
|
|
420
423
|
async function resolveVoiceFriendContext(options, input) {
|
|
421
424
|
const agentRoot = resolveTwilioPhoneAgentRoot(options);
|
|
422
425
|
const friendStore = new friends_1.FileFriendStore(path.join(agentRoot, "friends"));
|
|
423
426
|
const explicitFriendId = input.friendId?.trim();
|
|
427
|
+
const remotePhone = (0, phone_1.normalizeTwilioE164PhoneNumber)(input.remotePhone);
|
|
424
428
|
if (explicitFriendId && isSafeVoiceFriendId(explicitFriendId)) {
|
|
425
429
|
const existing = await friendStore.get(explicitFriendId);
|
|
426
|
-
|
|
430
|
+
// An outbound job names a friend, but the person who answers is whoever has the dialed number:
|
|
431
|
+
// a number that is not on the friend's record gets resolved like any other caller.
|
|
432
|
+
if (existing && existing.id === explicitFriendId && (!remotePhone || friendHasPhone(existing, remotePhone))) {
|
|
427
433
|
return {
|
|
428
434
|
friendId: existing.id,
|
|
429
435
|
friendStore,
|
|
@@ -431,7 +437,6 @@ async function resolveVoiceFriendContext(options, input) {
|
|
|
431
437
|
};
|
|
432
438
|
}
|
|
433
439
|
}
|
|
434
|
-
const remotePhone = (0, phone_1.normalizeTwilioE164PhoneNumber)(input.remotePhone);
|
|
435
440
|
const provider = remotePhone ? "imessage-handle" : "local";
|
|
436
441
|
const externalId = remotePhone || friendIdFromCaller("", input.callSid);
|
|
437
442
|
const resolver = new friends_1.FriendResolver(friendStore, {
|
|
@@ -481,6 +486,14 @@ function twilioPhoneVoiceSessionKey(options) {
|
|
|
481
486
|
return `twilio-phone-line-${lineSegment}`;
|
|
482
487
|
return `twilio-phone-${safeSegment(options.callSid ?? "incoming")}`;
|
|
483
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* A Realtime `response.create` that carries `instructions` replaces the session instructions for
|
|
491
|
+
* that response, so a greeting sent that way knows nothing about who the agent is. Without this
|
|
492
|
+
* line the model introduced itself as "ChatGPT" in 3 of 6 live calls.
|
|
493
|
+
*/
|
|
494
|
+
function realtimeGreetingInstructions(agentName, prompt) {
|
|
495
|
+
return `You are ${agentName}. If you introduce yourself, use that name; never call yourself ChatGPT or an OpenAI assistant.\n${prompt}`;
|
|
496
|
+
}
|
|
484
497
|
function callConnectedPrompt(params) {
|
|
485
498
|
const from = params.From?.trim();
|
|
486
499
|
const to = params.To?.trim();
|
|
@@ -2098,7 +2111,7 @@ class TwilioOpenAIRealtimeMediaStreamSession {
|
|
|
2098
2111
|
createdAt: new Date().toISOString(),
|
|
2099
2112
|
}, { From: this.to, To: this.from })
|
|
2100
2113
|
: callConnectedPrompt({ From: this.from, To: this.to });
|
|
2101
|
-
this.requestRealtimeResponse({ instructions: promptText });
|
|
2114
|
+
this.requestRealtimeResponse({ instructions: realtimeGreetingInstructions(this.options.agentName, promptText) });
|
|
2102
2115
|
}
|
|
2103
2116
|
handleMedia(media) {
|
|
2104
2117
|
const payload = stringField(media?.payload);
|
|
@@ -3350,7 +3363,7 @@ class OpenAISipPhoneSession {
|
|
|
3350
3363
|
return;
|
|
3351
3364
|
this.initialGreetingSent = true;
|
|
3352
3365
|
this.requestRealtimeResponse({
|
|
3353
|
-
instructions: openAISipCallConnectedPrompt(this.metadata, this.options.openaiRealtime?.voiceStyle),
|
|
3366
|
+
instructions: realtimeGreetingInstructions(this.options.agentName, openAISipCallConnectedPrompt(this.metadata, this.options.openaiRealtime?.voiceStyle)),
|
|
3354
3367
|
});
|
|
3355
3368
|
}
|
|
3356
3369
|
handleOpenAIMessage(raw) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.892",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.892",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|