@ouro.bot/cli 0.1.0-alpha.560 → 0.1.0-alpha.562

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/README.md CHANGED
@@ -105,7 +105,7 @@ Task docs do not live in this repo anymore. Planning and doing docs live in the
105
105
  - Human TTY commands share one CLI surface family: bare `ouro` opens the home deck, `ouro up` uses the boot checklist, `ouro connect`/`ouro auth verify`/`ouro repair` agree on provider and vault truth, and `ouro help`/`ouro whoami`/`ouro versions`/`ouro hatch` render through the same Ouro-branded wizard/guide language instead of raw transcript walls. Orientation commands such as root `ouro connect` may use shorter live probes, while startup and verification commands own durable readiness updates.
106
106
  - Human-facing CLI commands that can wait on browser auth, vault IO, daemon startup, daemon restart, provider checks, or connector setup use a shared progress checklist. If a cursor may blink for more than a few seconds, the command should print or animate the current step instead of going quiet.
107
107
  - CLI commands that mutate bundle config, such as vault setup or `ouro connect bluebubbles`, run bundle sync after the change when `sync.enabled` is true and report a compact `bundle sync:` line.
108
- - Voice is transcript-first: voice sessions use the ordinary `state/sessions/<friend>/voice/<key>.json` session path and appear in Ouro Mailbox as text transcripts. ElevenLabs API credentials live in portable `runtime/config` at `integrations.elevenLabsApiKey`; Whisper.cpp CLI/model paths live in the machine runtime item at `voice.whisperCliPath` and `voice.whisperModelPath`.
108
+ - Voice is transcript-first: voice sessions use the ordinary `state/sessions/<friend>/voice/<key>.json` session path and appear in Ouro Mailbox as text transcripts. ElevenLabs API credentials live in portable `runtime/config` at `integrations.elevenLabsApiKey` and `integrations.elevenLabsVoiceId`; Whisper.cpp CLI/model paths live in the machine runtime item at `voice.whisperCliPath` and `voice.whisperModelPath`. Phone calls, browser meetings, and local microphone capture are transports under the single `voice` sense, not separate senses; the Twilio phone transport uses Twilio Record -> Whisper.cpp -> voice session -> ElevenLabs -> Twilio Play.
109
109
  - The daemon discovers bundles dynamically from `~/AgentBundles`.
110
110
  - `ouro status` reports version, last-updated time, discovered agents, senses, and workers.
111
111
  - `bundle-meta.json` tracks the runtime version that last touched a bundle.
package/changelog.json CHANGED
@@ -1,6 +1,22 @@
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.562",
6
+ "changes": [
7
+ "BlueBubbles outbound delivery now blocks narrow internal/meta markers such as `[surfaced from inner dialog]`, `[pending from ...]:`, routing-control prompt sections, and `<think>` tags before they can reach iMessage.",
8
+ "The guard fails closed across surface, proactive BlueBubbles sends, pending-drain retries, normal flush, and speak/flushNow paths so blocked internal text is logged instead of queued for later delivery.",
9
+ "Regression coverage now keeps ordinary user-facing prose about inner-dialog concepts deliverable while preventing the reported internal surfaced-thought leakage path."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.561",
14
+ "changes": [
15
+ "Voice golden-path orchestration now accepts meeting URLs, checks local BlackHole/Multi-Output readiness, runs Whisper.cpp STT, routes text through ordinary `voice` sessions, streams ElevenLabs TTS, and writes playback-ready audio artifacts.",
16
+ "Voice runtime edges now include default Node Whisper.cpp process execution, default Node WebSocket adaptation for ElevenLabs, and tested playback artifact handling while keeping credentials injected at runtime.",
17
+ "Shared sense turns now store sessions under the explicit agent's bundle instead of depending on process argv for the session path, and voice setup guidance now reflects meeting URL intake plus live-browser handoff limits truthfully."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.560",
6
22
  "changes": [
@@ -2540,6 +2540,10 @@ async function buildConnectMenu(agent, deps, onProgress) {
2540
2540
  const elevenLabsApiKey = runtimeConfig.ok
2541
2541
  ? readRuntimeConfigString(runtimeConfig.config, "integrations.elevenLabsApiKey")
2542
2542
  : null;
2543
+ const elevenLabsVoiceId = runtimeConfig.ok
2544
+ ? readRuntimeConfigString(runtimeConfig.config, "integrations.elevenLabsVoiceId")
2545
+ ?? readRuntimeConfigString(runtimeConfig.config, "voice.elevenLabsVoiceId")
2546
+ : null;
2543
2547
  const shouldVerifyPerplexity = runtimeConfig.ok && !!perplexityApiKey;
2544
2548
  const shouldVerifyEmbeddings = runtimeConfig.ok && !!embeddingsApiKey;
2545
2549
  let perplexityVerification;
@@ -2613,6 +2617,7 @@ async function buildConnectMenu(agent, deps, onProgress) {
2613
2617
  const voiceStatus = runtimeConfig.ok
2614
2618
  ? machineRuntime.ok
2615
2619
  ? elevenLabsApiKey
2620
+ && elevenLabsVoiceId
2616
2621
  && hasRuntimeConfigValue(machineRuntime.config, "voice.whisperCliPath")
2617
2622
  && hasRuntimeConfigValue(machineRuntime.config, "voice.whisperModelPath")
2618
2623
  && voiceEnabled
@@ -2719,6 +2724,7 @@ async function buildConnectMenu(agent, deps, onProgress) {
2719
2724
  detailLines: runtimeConfig.ok && machineRuntime.ok
2720
2725
  ? [
2721
2726
  elevenLabsApiKey ? "ElevenLabs API key saved in portable runtime config" : "missing integrations.elevenLabsApiKey",
2727
+ elevenLabsVoiceId ? "ElevenLabs voice ID saved in portable runtime config" : "missing integrations.elevenLabsVoiceId",
2722
2728
  hasRuntimeConfigValue(machineRuntime.config, "voice.whisperCliPath") ? "Whisper.cpp CLI path saved for this machine" : "missing voice.whisperCliPath",
2723
2729
  hasRuntimeConfigValue(machineRuntime.config, "voice.whisperModelPath") ? "Whisper.cpp model path saved for this machine" : "missing voice.whisperModelPath",
2724
2730
  ]
@@ -4270,13 +4276,22 @@ function connectMenuTarget(answer) {
4270
4276
  async function executeConnectVoice(agent, deps) {
4271
4277
  const message = [
4272
4278
  `Voice foundation for ${agent}`,
4273
- "Configure the portable ElevenLabs API key with:",
4279
+ "Configure portable ElevenLabs settings with:",
4274
4280
  ` ouro vault config set --agent ${agent} --key integrations.elevenLabsApiKey`,
4281
+ ` ouro vault config set --agent ${agent} --key integrations.elevenLabsVoiceId`,
4275
4282
  "Configure this machine's Whisper.cpp attachment with:",
4276
4283
  ` ouro vault config set --agent ${agent} --scope machine --key voice.whisperCliPath`,
4277
4284
  ` ouro vault config set --agent ${agent} --scope machine --key voice.whisperModelPath`,
4285
+ "Optional Twilio phone testing setup:",
4286
+ ` ouro vault config set --agent ${agent} --key voice.twilioAccountSid`,
4287
+ ` ouro vault config set --agent ${agent} --key voice.twilioAuthToken`,
4288
+ ` ouro vault config set --agent ${agent} --scope machine --key voice.twilioPublicUrl`,
4289
+ ` ouro vault config set --agent ${agent} --scope machine --key voice.twilioPort --value 18910`,
4290
+ ` ouro vault config set --agent ${agent} --scope machine --key voice.twilioDefaultFriendId --value ari`,
4291
+ ` node dist/senses/voice-twilio-entry.js --agent ${agent} --port 18910 --public-url https://<cloudflare-tunnel>`,
4292
+ `Set the Twilio number's Voice webhook to POST https://<cloudflare-tunnel>/voice/twilio/incoming.`,
4278
4293
  "Then enable agent.json: senses.voice.enabled = true and restart with `ouro up`.",
4279
- "Meeting-link joining and browser/system audio routing are tracked as the next milestone.",
4294
+ "Meeting links use URL intake plus BlackHole/Multi-Output readiness checks. Phone testing uses Twilio Record -> Whisper.cpp -> voice session -> ElevenLabs -> Twilio Play.",
4280
4295
  ].join("\n");
4281
4296
  deps.writeStdout(message);
4282
4297
  return message;
@@ -197,6 +197,9 @@ function senseFactsFromRuntimeConfig(agent, senses, runtimeConfig, machineRuntim
197
197
  const missing = [];
198
198
  if (!textField(integrations, "elevenLabsApiKey"))
199
199
  missing.push("integrations.elevenLabsApiKey");
200
+ if (!textField(integrations, "elevenLabsVoiceId") && !textField(payload.voice, "elevenLabsVoiceId")) {
201
+ missing.push("integrations.elevenLabsVoiceId");
202
+ }
200
203
  if (!textField(voice, "whisperCliPath"))
201
204
  missing.push("voice.whisperCliPath");
202
205
  if (!textField(voice, "whisperModelPath"))
@@ -165,6 +165,7 @@ function readSenseStatusLines() {
165
165
  const bluebubbles = recordOrUndefined(machinePayload.bluebubbles) ?? recordOrUndefined(payload.bluebubbles);
166
166
  const mailroom = recordOrUndefined(runtimePayload.mailroom) ?? recordOrUndefined(payload.mailroom);
167
167
  const voice = recordOrUndefined(machinePayload.voice) ?? recordOrUndefined(payload.voice);
168
+ const portableVoice = recordOrUndefined(runtimePayload.voice) ?? recordOrUndefined(payload.voice);
168
169
  const integrations = recordOrUndefined(runtimePayload.integrations) ?? recordOrUndefined(payload.integrations);
169
170
  const privateKeys = mailroom?.privateKeys;
170
171
  const configured = {
@@ -172,7 +173,10 @@ function readSenseStatusLines() {
172
173
  teams: hasTextField(teams, "clientId") && hasTextField(teams, "clientSecret") && hasTextField(teams, "tenantId"),
173
174
  bluebubbles: hasTextField(bluebubbles, "serverUrl") && hasTextField(bluebubbles, "password"),
174
175
  mail: hasTextField(mailroom, "mailboxAddress") && !!privateKeys && typeof privateKeys === "object" && !Array.isArray(privateKeys),
175
- voice: hasTextField(integrations, "elevenLabsApiKey") && hasTextField(voice, "whisperCliPath") && hasTextField(voice, "whisperModelPath"),
176
+ voice: hasTextField(integrations, "elevenLabsApiKey")
177
+ && (hasTextField(integrations, "elevenLabsVoiceId") || hasTextField(portableVoice, "elevenLabsVoiceId"))
178
+ && hasTextField(voice, "whisperCliPath")
179
+ && hasTextField(voice, "whisperModelPath"),
176
180
  };
177
181
  const rows = [
178
182
  { label: "CLI", status: "interactive" },
@@ -441,6 +441,7 @@ function localSenseStatusLines() {
441
441
  const bluebubbles = recordOrUndefined(machinePayload.bluebubbles) ?? recordOrUndefined(payload.bluebubbles);
442
442
  const mailroom = recordOrUndefined(runtimePayload.mailroom) ?? recordOrUndefined(payload.mailroom);
443
443
  const voice = recordOrUndefined(machinePayload.voice) ?? recordOrUndefined(payload.voice);
444
+ const portableVoice = recordOrUndefined(runtimePayload.voice) ?? recordOrUndefined(payload.voice);
444
445
  const integrations = recordOrUndefined(runtimePayload.integrations) ?? recordOrUndefined(payload.integrations);
445
446
  const privateKeys = mailroom?.privateKeys;
446
447
  const configured = {
@@ -448,7 +449,10 @@ function localSenseStatusLines() {
448
449
  teams: hasTextField(teams, "clientId") && hasTextField(teams, "clientSecret") && hasTextField(teams, "tenantId"),
449
450
  bluebubbles: hasTextField(bluebubbles, "serverUrl") && hasTextField(bluebubbles, "password"),
450
451
  mail: hasTextField(mailroom, "mailboxAddress") && !!privateKeys && typeof privateKeys === "object" && !Array.isArray(privateKeys),
451
- voice: hasTextField(integrations, "elevenLabsApiKey") && hasTextField(voice, "whisperCliPath") && hasTextField(voice, "whisperModelPath"),
452
+ voice: hasTextField(integrations, "elevenLabsApiKey")
453
+ && (hasTextField(integrations, "elevenLabsVoiceId") || hasTextField(portableVoice, "elevenLabsVoiceId"))
454
+ && hasTextField(voice, "whisperCliPath")
455
+ && hasTextField(voice, "whisperModelPath"),
452
456
  };
453
457
  const rows = [
454
458
  { label: "CLI", status: "interactive" },
@@ -502,7 +506,7 @@ function senseRuntimeGuidance(channel, preReadStatusLines) {
502
506
  lines.push("mail validation diagnostics: health checks, bounded mail tools, access logs, and UI inspection can support validation, but they are evidence inside those paths, not additional paths. If asked to name golden paths, do not include diagnostic commands, tool names, or status checks in the answer.");
503
507
  lines.push("mail diagnostic naming: `ouro doctor` is installation-wide; do not invent `ouro doctor --agent <agent>`.");
504
508
  lines.push("mail setup boundaries: do not invent `ouro auth verify --provider mail`, HEY OAuth, HEY IMAP, `ouro mcp call mail ...`, policy flags, autonomous sending, destructive mail actions, or production MX/DNS/forwarding changes. HEY export, HEY forwarding, DNS, MX cutover, sending, and destructive actions require explicit human confirmation.");
505
- lines.push("voice setup truth: voice sessions are transcript-first local sessions. ElevenLabs credentials belong in portable runtime/config at `integrations.elevenLabsApiKey`; Whisper.cpp CLI/model paths belong in the machine runtime item under `voice.whisperCliPath` and `voice.whisperModelPath`. Meeting-link joining and browser/system audio routing are a later milestone, not current setup truth.");
509
+ lines.push("voice setup truth: voice sessions are transcript-first local sessions. ElevenLabs credentials belong in portable runtime/config at `integrations.elevenLabsApiKey` and `integrations.elevenLabsVoiceId`; Whisper.cpp CLI/model paths belong in the machine runtime item under `voice.whisperCliPath` and `voice.whisperModelPath`. Meeting links have URL intake and local BlackHole/Multi-Output readiness checks; phone testing uses Twilio Record -> Whisper.cpp -> voice session -> ElevenLabs -> Twilio Play. Live browser join/injection remains an explicit handoff edge until provider automation lands.");
506
510
  if (channel === "cli") {
507
511
  lines.push("cli is interactive: it is available when the user opens it, not something `ouro up` daemonizes.");
508
512
  }
@@ -656,6 +656,12 @@ exports.sessionToolDefinitions = [
656
656
  detail: "bluebubbles could not resolve a routable target for that session",
657
657
  };
658
658
  }
659
+ if (result.reason === "blocked_meta_content") {
660
+ return {
661
+ status: "blocked",
662
+ detail: "blocked: contains internal meta markers",
663
+ };
664
+ }
659
665
  if (result.reason === "send_error") {
660
666
  return {
661
667
  status: "failed",
@@ -39,6 +39,8 @@ const identity_1 = require("../heart/identity");
39
39
  const surface_tool_1 = require("../senses/surface-tool");
40
40
  const obligations_1 = require("../arc/obligations");
41
41
  const session_activity_1 = require("../heart/session-activity");
42
+ const bluebubbles_meta_guard_1 = require("../senses/bluebubbles-meta-guard");
43
+ const runtime_1 = require("../nerves/runtime");
42
44
  const path = __importStar(require("path"));
43
45
  // Surface tool schema — canonical home. Handler lives in senses/surface-tool.ts.
44
46
  exports.surfaceToolDef = {
@@ -71,6 +73,21 @@ exports.surfaceToolDef = {
71
73
  exports.surfaceToolDefinition = {
72
74
  tool: exports.surfaceToolDef,
73
75
  handler: async (args, ctx) => {
76
+ const rawContent = args.content ?? "";
77
+ if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(rawContent)) {
78
+ (0, runtime_1.emitNervesEvent)({
79
+ level: "warn",
80
+ component: "repertoire",
81
+ event: "tools.surface_meta_blocked",
82
+ message: "surface tool blocked: internal meta markers in content",
83
+ meta: {
84
+ hasDelegationId: Boolean(args.delegationId),
85
+ hasFriendId: Boolean(args.friendId),
86
+ contentLength: rawContent.length,
87
+ },
88
+ });
89
+ return "failed — blocked: contains internal meta markers";
90
+ }
74
91
  const queue = ctx?.delegatedOrigins ?? [];
75
92
  const agentName = (() => { try {
76
93
  return (0, identity_1.getAgentName)();
@@ -69,6 +69,7 @@ const prompt_1 = require("../../mind/prompt");
69
69
  const mcp_manager_1 = require("../../repertoire/mcp-manager");
70
70
  const runtime_1 = require("../../nerves/runtime");
71
71
  const proactive_content_guard_1 = require("../proactive-content-guard");
72
+ const bluebubbles_meta_guard_1 = require("../bluebubbles-meta-guard");
72
73
  const model_1 = require("./model");
73
74
  const client_1 = require("./client");
74
75
  const inbound_log_1 = require("./inbound-log");
@@ -616,6 +617,17 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
616
617
  if (!trimmed)
617
618
  return;
618
619
  textBuffer = "";
620
+ if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(trimmed)) {
621
+ (0, bluebubbles_meta_guard_1.emitBluebubblesMetaBlocked)({
622
+ site: "flushNow",
623
+ message: "bluebubbles speak text blocked: internal meta markers",
624
+ meta: {
625
+ chatGuid: chat.chatGuid ?? null,
626
+ messageLength: trimmed.length,
627
+ },
628
+ });
629
+ return;
630
+ }
619
631
  await client.sendText({
620
632
  chat,
621
633
  text: trimmed,
@@ -650,6 +662,17 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
650
662
  enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
651
663
  await queue;
652
664
  }
665
+ if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(trimmed)) {
666
+ (0, bluebubbles_meta_guard_1.emitBluebubblesMetaBlocked)({
667
+ site: "flush",
668
+ message: "bluebubbles outbound text blocked: internal meta markers",
669
+ meta: {
670
+ chatGuid: chat.chatGuid ?? null,
671
+ messageLength: trimmed.length,
672
+ },
673
+ });
674
+ return;
675
+ }
653
676
  await client.sendText({
654
677
  chat,
655
678
  text: trimmed,
@@ -1925,6 +1948,17 @@ function buildChatRefForSessionKey(friend, sessionKey) {
1925
1948
  };
1926
1949
  }
1927
1950
  async function sendProactiveBlueBubblesMessageToSession(params, deps = {}) {
1951
+ if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(params.text)) {
1952
+ (0, bluebubbles_meta_guard_1.emitBluebubblesMetaBlocked)({
1953
+ site: "proactive",
1954
+ message: "bluebubbles proactive send blocked: internal meta markers",
1955
+ meta: {
1956
+ friendId: params.friendId,
1957
+ sessionKey: params.sessionKey,
1958
+ },
1959
+ });
1960
+ return { delivered: false, reason: "blocked_meta_content" };
1961
+ }
1928
1962
  const resolvedDeps = { ...defaultDeps, ...deps };
1929
1963
  const client = resolvedDeps.createClient();
1930
1964
  const store = resolvedDeps.createFriendStore();
@@ -2131,6 +2165,22 @@ async function drainAndSendPendingBlueBubbles(deps = {}, pendingRoot) {
2131
2165
  catch { /* ignore */ }
2132
2166
  continue;
2133
2167
  }
2168
+ if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(messageText)) {
2169
+ result.skipped++;
2170
+ try {
2171
+ fs.unlinkSync(filePath);
2172
+ }
2173
+ catch { /* ignore */ }
2174
+ (0, bluebubbles_meta_guard_1.emitBluebubblesMetaBlocked)({
2175
+ site: "drain",
2176
+ message: "bluebubbles drain blocked: internal meta markers",
2177
+ meta: {
2178
+ friendId,
2179
+ filePath,
2180
+ },
2181
+ });
2182
+ continue;
2183
+ }
2134
2184
  const internalBlockReason = (0, proactive_content_guard_1.getProactiveInternalContentBlockReason)(messageText);
2135
2185
  if (internalBlockReason) {
2136
2186
  result.skipped++;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ // Outbound BlueBubbles meta-content guard.
3
+ //
4
+ // Blocks accidental delivery of internal/meta text — pipeline section markers,
5
+ // surfacing-mechanics prefixes, reasoning tags — to the live iMessage channel.
6
+ // Failure mode is "drop and log", never queue for later delivery.
7
+ //
8
+ // Patterns are deliberately narrow: bracketed system markers and angle-bracket
9
+ // reasoning tags. Plain prose mentioning "inner dialog" or "attention queue"
10
+ // is NOT blocked, so user-facing replies that legitimately discuss those
11
+ // concepts still pass.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.containsInternalMetaMarkers = containsInternalMetaMarkers;
14
+ exports.emitBluebubblesMetaBlocked = emitBluebubblesMetaBlocked;
15
+ const runtime_1 = require("../nerves/runtime");
16
+ const META_CONTENT_PATTERNS = [
17
+ /\[surfaced from inner dialog\]/i,
18
+ /\[pending from [^\]]+\]:/i,
19
+ /\[conversation scope:/i,
20
+ /\[recent active lanes\]/i,
21
+ /\[routing control:/i,
22
+ /<\/?think>/i,
23
+ ];
24
+ function containsInternalMetaMarkers(text) {
25
+ if (!text)
26
+ return false;
27
+ return META_CONTENT_PATTERNS.some((pattern) => pattern.test(text));
28
+ }
29
+ function emitBluebubblesMetaBlocked(options) {
30
+ (0, runtime_1.emitNervesEvent)({
31
+ level: "warn",
32
+ component: "senses",
33
+ event: "senses.bluebubbles_meta_blocked",
34
+ message: options.message,
35
+ meta: {
36
+ site: options.site,
37
+ ...options.meta,
38
+ },
39
+ });
40
+ }
@@ -43,6 +43,7 @@ exports.stripThinkBlocks = stripThinkBlocks;
43
43
  exports.runSenseTurn = runSenseTurn;
44
44
  const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
+ const fs = __importStar(require("fs"));
46
47
  const core_1 = require("../heart/core");
47
48
  const identity_1 = require("../heart/identity");
48
49
  const config_1 = require("../heart/config");
@@ -130,7 +131,9 @@ async function runSenseTurn(options) {
130
131
  // Initialize MCP manager so MCP tools appear as first-class tools in the agent's tool list
131
132
  const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
132
133
  // Session path and loading
133
- const sessPath = (0, config_1.sessionPath)(friendId, channel, sessionKey);
134
+ const sessionDir = path.join(agentRoot, "state", "sessions", friendId, channel);
135
+ fs.mkdirSync(sessionDir, { recursive: true });
136
+ const sessPath = path.join(sessionDir, `${(0, config_1.sanitizeKey)(sessionKey)}.json`);
134
137
  const existing = (0, context_1.loadSession)(sessPath);
135
138
  let sessionState = existing?.state;
136
139
  let persistPromise;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createNodeVoiceCommandRunner = createNodeVoiceCommandRunner;
4
+ exports.inspectVoiceAudioRouting = inspectVoiceAudioRouting;
5
+ const child_process_1 = require("child_process");
6
+ const runtime_1 = require("../../nerves/runtime");
7
+ function createNodeVoiceCommandRunner() {
8
+ return (command, args, options) => new Promise((resolve, reject) => {
9
+ const child = (0, child_process_1.spawn)(command, args, { stdio: ["ignore", "pipe", "pipe"] });
10
+ const stdout = [];
11
+ const stderr = [];
12
+ const timer = setTimeout(() => {
13
+ child.kill("SIGTERM");
14
+ reject(new Error(`command timed out after ${options.timeoutMs}ms`));
15
+ }, options.timeoutMs);
16
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
17
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
18
+ child.on("error", (error) => {
19
+ clearTimeout(timer);
20
+ reject(error);
21
+ });
22
+ child.on("close", (exitCode) => {
23
+ clearTimeout(timer);
24
+ resolve({
25
+ stdout: Buffer.concat(stdout).toString("utf8"),
26
+ stderr: Buffer.concat(stderr).toString("utf8"),
27
+ exitCode: exitCode ?? 0,
28
+ });
29
+ });
30
+ });
31
+ }
32
+ function parseDeviceLines(output) {
33
+ return output
34
+ .split(/\r?\n/)
35
+ .map((line) => line.trim())
36
+ .filter(Boolean);
37
+ }
38
+ function commandFailureMessage(exitCode, result) {
39
+ const stderr = result.stderr?.trim();
40
+ if (stderr)
41
+ return stderr;
42
+ const stdout = result.stdout?.trim();
43
+ if (stdout)
44
+ return stdout;
45
+ return `exit ${exitCode}`;
46
+ }
47
+ function setupGuidance(missing, currentOutput, outputDeviceName) {
48
+ const guidance = missing.map((device) => `Install or configure the local audio device: ${device}.`);
49
+ if (currentOutput && currentOutput !== outputDeviceName) {
50
+ guidance.push(`Browser meeting audio should be routed through ${outputDeviceName}; current output is ${currentOutput}.`);
51
+ }
52
+ return guidance;
53
+ }
54
+ async function inspectVoiceAudioRouting(options = {}) {
55
+ const commandRunner = options.commandRunner ?? createNodeVoiceCommandRunner();
56
+ const switchAudioSourcePath = options.switchAudioSourcePath ?? "SwitchAudioSource";
57
+ const captureDeviceName = options.captureDeviceName ?? "BlackHole 2ch";
58
+ const outputDeviceName = options.outputDeviceName ?? "Multi-Output Device";
59
+ const timeoutMs = options.timeoutMs ?? 5_000;
60
+ try {
61
+ const devicesResult = await commandRunner(switchAudioSourcePath, ["-a"], { timeoutMs });
62
+ if (typeof devicesResult.exitCode === "number" && devicesResult.exitCode !== 0) {
63
+ throw new Error(commandFailureMessage(devicesResult.exitCode, devicesResult));
64
+ }
65
+ const currentResult = await commandRunner(switchAudioSourcePath, ["-c"], { timeoutMs });
66
+ if (typeof currentResult.exitCode === "number" && currentResult.exitCode !== 0) {
67
+ throw new Error(commandFailureMessage(currentResult.exitCode, currentResult));
68
+ }
69
+ const devices = parseDeviceLines(devicesResult.stdout ?? "");
70
+ const currentOutput = parseDeviceLines(currentResult.stdout ?? "")[0] ?? null;
71
+ const hasCaptureDevice = devices.includes(captureDeviceName);
72
+ const hasOutputDevice = devices.includes(outputDeviceName);
73
+ const missing = [
74
+ ...(hasCaptureDevice ? [] : [captureDeviceName]),
75
+ ...(hasOutputDevice ? [] : [outputDeviceName]),
76
+ ];
77
+ const result = {
78
+ status: missing.length === 0 ? "ready" : "needs_setup",
79
+ hasCaptureDevice,
80
+ hasOutputDevice,
81
+ currentOutput,
82
+ missing,
83
+ guidance: setupGuidance(missing, currentOutput, outputDeviceName),
84
+ };
85
+ (0, runtime_1.emitNervesEvent)({
86
+ component: "senses",
87
+ event: "senses.voice_audio_routing_checked",
88
+ message: "voice audio routing readiness checked",
89
+ meta: {
90
+ status: result.status,
91
+ hasCaptureDevice,
92
+ hasOutputDevice,
93
+ currentOutput,
94
+ missing,
95
+ },
96
+ });
97
+ return result;
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ const result = {
102
+ status: "unknown",
103
+ hasCaptureDevice: false,
104
+ hasOutputDevice: false,
105
+ currentOutput: null,
106
+ missing: [captureDeviceName, outputDeviceName],
107
+ guidance: setupGuidance([captureDeviceName, outputDeviceName], null, outputDeviceName),
108
+ error: message,
109
+ };
110
+ (0, runtime_1.emitNervesEvent)({
111
+ level: "error",
112
+ component: "senses",
113
+ event: "senses.voice_audio_routing_error",
114
+ message: "voice audio routing readiness check failed",
115
+ meta: { error: message, missing: result.missing },
116
+ });
117
+ return result;
118
+ }
119
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_ELEVENLABS_MIME_TYPE = exports.DEFAULT_ELEVENLABS_OUTPUT_FORMAT = exports.DEFAULT_ELEVENLABS_MODEL_ID = void 0;
4
+ exports.createNodeElevenLabsSocketFactory = createNodeElevenLabsSocketFactory;
4
5
  exports.createElevenLabsTtsClient = createElevenLabsTtsClient;
5
6
  const runtime_1 = require("../../nerves/runtime");
6
7
  exports.DEFAULT_ELEVENLABS_MODEL_ID = "eleven_flash_v2_5";
@@ -14,15 +15,67 @@ function elevenLabsStreamUrl(voiceId, modelId, outputFormat) {
14
15
  return `wss://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream-input?${params.toString()}`;
15
16
  }
16
17
  function payloadText(payload) {
18
+ if (payload && typeof payload === "object" && "data" in payload) {
19
+ return payloadText(payload.data);
20
+ }
17
21
  if (typeof payload === "string")
18
22
  return payload;
19
23
  if (Buffer.isBuffer(payload))
20
24
  return payload.toString("utf8");
21
25
  return String(payload ?? "");
22
26
  }
27
+ function createNodeElevenLabsSocketFactory(webSocketConstructor) {
28
+ const WebSocketConstructor = webSocketConstructor
29
+ ?? globalThis.WebSocket;
30
+ if (!WebSocketConstructor) {
31
+ throw new Error("global WebSocket is unavailable; inject an ElevenLabs socketFactory");
32
+ }
33
+ return (url) => {
34
+ const socket = new WebSocketConstructor(url);
35
+ const handlers = {
36
+ open: [],
37
+ message: [],
38
+ error: [],
39
+ close: [],
40
+ };
41
+ const emit = (event, payload) => {
42
+ for (const handler of handlers[event]) {
43
+ handler(payload);
44
+ }
45
+ };
46
+ const attach = (event) => {
47
+ const listener = (payload) => emit(event, payload);
48
+ if (typeof socket.addEventListener === "function") {
49
+ socket.addEventListener(event, listener);
50
+ }
51
+ else if (typeof socket.on === "function") {
52
+ socket.on(event, listener);
53
+ }
54
+ else {
55
+ socket[`on${event}`] = listener;
56
+ }
57
+ };
58
+ attach("open");
59
+ attach("message");
60
+ attach("error");
61
+ attach("close");
62
+ return {
63
+ on(event, handler) {
64
+ handlers[event].push(handler);
65
+ },
66
+ send(payload) {
67
+ socket.send(payload);
68
+ },
69
+ close() {
70
+ socket.close();
71
+ },
72
+ };
73
+ };
74
+ }
23
75
  function createElevenLabsTtsClient(options) {
24
76
  const modelId = options.modelId ?? exports.DEFAULT_ELEVENLABS_MODEL_ID;
25
77
  const outputFormat = options.outputFormat ?? exports.DEFAULT_ELEVENLABS_OUTPUT_FORMAT;
78
+ const socketFactory = options.socketFactory ?? createNodeElevenLabsSocketFactory();
26
79
  const mimeType = outputFormat === exports.DEFAULT_ELEVENLABS_OUTPUT_FORMAT
27
80
  ? exports.DEFAULT_ELEVENLABS_MIME_TYPE
28
81
  : "audio/mpeg";
@@ -40,7 +93,7 @@ function createElevenLabsTtsClient(options) {
40
93
  throw new Error("voice TTS text is empty");
41
94
  }
42
95
  const url = elevenLabsStreamUrl(options.voiceId, modelId, outputFormat);
43
- const socket = options.socketFactory(url);
96
+ const socket = socketFactory(url);
44
97
  const chunks = [];
45
98
  (0, runtime_1.emitNervesEvent)({
46
99
  component: "senses",