@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 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,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.891",
2
+ "runtimeVersion": "0.1.0-alpha.892",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -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.891</Repository>
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.soxPlaybackArgs = soxPlaybackArgs;
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
- /** Play raw mono 8 kHz mu-law from stdin to a CoreAudio device. */
66
- function soxPlaybackArgs(device) {
67
- return ["-q", "--buffer", "160", ...RAW_MULAW, "-", "-t", "coreaudio", device];
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 sox AND started when we recorded it is ours to kill (pids get reused). */
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) !== "sox")
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: files ? (0, local_audio_devices_1.filePlaybackArgs)(files.outputPath) : (0, local_audio_devices_1.soxPlaybackArgs)(local_audio_devices_1.PLAYBACK_DEVICE_NAME),
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
- if (existing && existing.id === explicitFriendId) {
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) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.891",
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.891",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.891",
3
+ "version": "0.1.0-alpha.892",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },