@ouro.bot/cli 0.1.0-alpha.887 → 0.1.0-alpha.889

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,18 @@
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.889",
6
+ "changes": [
7
+ "Agents can join a call on this Mac (FaceTime, Zoom, Meet, a podcast recorder) through BlackHole virtual audio: ouro voice join/leave/status and a family-only voice_join_local_audio tool, with a spoken AI disclosure, room-level trust, a two-tool allowlist, a single-join lock and fail-loud device checks."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.888",
12
+ "changes": [
13
+ "Answer gates (source grounding and brevity) now hold a plain-text reply as well as a settle answer, so a reply with no settle call can no longer name an unsourced cast or ignore a brevity request."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.887",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.887",
2
+ "runtimeVersion": "0.1.0-alpha.889",
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.887</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.889</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -1208,6 +1208,38 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1208
1208
  rejectedAttempt = [structuredClone(assistant)];
1209
1209
  nextAttemptControls = controls;
1210
1210
  };
1211
+ // Answer gates run only for agents that opt in (ToolContext.answerGates), on every reply the person would receive: a settle answer
1212
+ // and a plain-text reply with no tool call alike (alpha.886 replay: a plain-text reply skipped them and an invented cast went out).
1213
+ // A request-specific contract (options.requiredToolCalls) already validates its own terminal answer. Each gate has its own bounded
1214
+ // retry, and none spends the last provider iteration.
1215
+ const answerGateVerdict = (answer, intent) => {
1216
+ const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1217
+ const retriesLeft = providerIterations < stepBudget - 1;
1218
+ let groundingError = null;
1219
+ let unverifiedNames = null;
1220
+ const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer, userText: userTexts(messages).join("\n"), latestUserText: latestUserMessageText(messages.slice(0, turnStartLength)), knownText: priorTurnsText(messages.slice(0, turnStartLength)), tools: turnToolRecords }) : null;
1221
+ if (grounding) {
1222
+ if (groundingRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft) {
1223
+ groundingRejections += 1;
1224
+ groundingError = grounding.message;
1225
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer named things from a work that the turn did not look up; sending the model back", meta: { gate: "source_grounding", rejection: groundingRejections, cap: ANSWER_GATE_MAX_REJECTIONS, lookupsRun: grounding.lookups } });
1226
+ }
1227
+ else {
1228
+ unverifiedNames = grounding.names;
1229
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1230
+ }
1231
+ }
1232
+ let brevityError = null;
1233
+ if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1234
+ && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
1235
+ brevityError = (0, reply_style_1.briefStyleViolation)(answer, intent);
1236
+ if (brevityError) {
1237
+ brevityRejections += 1;
1238
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
1239
+ }
1240
+ }
1241
+ return { error: groundingError ?? brevityError, unverifiedNames };
1242
+ };
1211
1243
  const rejectToolBatch = (assistant, reasons) => {
1212
1244
  rejectAttempt(assistant, assistant.tool_calls.map((call, index) => ({
1213
1245
  role: "tool", tool_call_id: call.id, content: reasons[index],
@@ -1774,6 +1806,24 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1774
1806
  }]);
1775
1807
  continue;
1776
1808
  }
1809
+ // A plain-text reply reaches the person just as a settle answer does, so the answer gates hold it too.
1810
+ const textAnswer = isPrivateRuntimeChannel ? "" : stripThinkBlocksForViolationCheck(result.content).trim();
1811
+ const textVerdict = textAnswer ? answerGateVerdict(textAnswer) : null;
1812
+ if (textVerdict?.error) {
1813
+ streamCallbackBuffer?.discard();
1814
+ callbacks.onClearText?.();
1815
+ rejectAttempt(msg, [{ role: "user", content: textVerdict.error }]);
1816
+ continue;
1817
+ }
1818
+ if (textVerdict?.unverifiedNames) {
1819
+ const disclosed = (0, source_grounding_1.withUnverifiedDisclosure)(textAnswer, textVerdict.unverifiedNames);
1820
+ streamCallbackBuffer?.discard();
1821
+ callbacks.onClearText?.();
1822
+ callbacks.onTextChunk(disclosed);
1823
+ pushGenerated({ ...msg, content: disclosed });
1824
+ done = true;
1825
+ continue;
1826
+ }
1777
1827
  // Legitimate text-only response, or cap reached — accept as-is.
1778
1828
  await streamCallbackBuffer?.flush();
1779
1829
  pushGenerated(msg);
@@ -1976,34 +2026,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1976
2026
  sawSettleContentMismatch = true;
1977
2027
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
1978
2028
  }
1979
- // Answer gates run only for agents that opt in (ToolContext.answerGates). A request-specific contract (options.requiredToolCalls)
1980
- // already validates its own terminal answer. Each gate has its own bounded retry, and none spends the last provider iteration.
1981
- const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1982
- const retriesLeft = providerIterations < stepBudget - 1;
1983
- let groundingError = null;
1984
- let unverifiedNames = null;
1985
- const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer: deliveredAnswer, userText: userTexts(messages).join("\n"), latestUserText: latestUserMessageText(messages.slice(0, turnStartLength)), knownText: priorTurnsText(messages.slice(0, turnStartLength)), tools: turnToolRecords }) : null;
1986
- if (grounding) {
1987
- if (groundingRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft) {
1988
- groundingRejections += 1;
1989
- groundingError = grounding.message;
1990
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "settle answer named things from a work that the turn did not look up; sending the model back", meta: { gate: "source_grounding", rejection: groundingRejections, cap: ANSWER_GATE_MAX_REJECTIONS, lookupsRun: grounding.lookups } });
1991
- }
1992
- else {
1993
- unverifiedNames = grounding.names;
1994
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1995
- }
1996
- }
1997
- let brevityError = null;
1998
- if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1999
- && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
2000
- brevityError = (0, reply_style_1.briefStyleViolation)(deliveredAnswer, intent);
2001
- if (brevityError) {
2002
- brevityRejections += 1;
2003
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "settle answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
2004
- }
2005
- }
2006
- const answerGateError = groundingError ?? brevityError;
2029
+ const { error: answerGateError, unverifiedNames } = answerGateVerdict(deliveredAnswer, intent);
2007
2030
  const retryError = contentMismatch
2008
2031
  ?? answerGateError
2009
2032
  ?? privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
@@ -8220,6 +8220,24 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8220
8220
  return executeLegacyConfigModel(command, deps);
8221
8221
  }
8222
8222
  /* v8 ignore stop */
8223
+ // ── voice join / leave / status (local audio lane; no daemon socket, control is a local unix socket) ──
8224
+ if (command.kind === "voice.join" || command.kind === "voice.leave" || command.kind === "voice.status") {
8225
+ const voice = await Promise.resolve().then(() => __importStar(require("../../senses/voice/local-audio-join")));
8226
+ const voiceDeps = voice.defaultLocalAudioJoinDeps();
8227
+ let message;
8228
+ if (command.kind === "voice.join") {
8229
+ const summary = await voice.runLocalAudioJoin(command.request, voiceDeps);
8230
+ message = `Local audio session ${summary.callSid} ended (${summary.reason}) after ${Math.round(summary.durationMs / 1000)}s.`;
8231
+ }
8232
+ else if (command.kind === "voice.leave") {
8233
+ message = await voice.runLocalAudioLeave(command.agent, voiceDeps);
8234
+ }
8235
+ else {
8236
+ message = await voice.runLocalAudioStatus(command.agent, voiceDeps);
8237
+ }
8238
+ deps.writeStdout(message);
8239
+ return message;
8240
+ }
8223
8241
  // ── whoami (local, no daemon socket needed) ──
8224
8242
  if (command.kind === "whoami") {
8225
8243
  const formatWhoami = (agentName, homePath, bonesVersion) => {
@@ -99,6 +99,13 @@ exports.COMMAND_REGISTRY = {
99
99
  usage: "ouro whoami [--agent <name>]",
100
100
  example: "ouro whoami",
101
101
  },
102
+ voice: {
103
+ category: "Agents",
104
+ description: "Join a call on this Mac through the BlackHole audio devices, or leave it",
105
+ usage: "ouro voice join --agent <name> [--friend <id>] [--participants <text>] [--occasion <text>] [--mode conversation] | ouro voice leave --agent <name> | ouro voice status --agent <name>",
106
+ example: "ouro voice join --agent slugger --participants \"Ari and Sam\" --occasion \"podcast prep\"",
107
+ subcommands: ["join", "leave", "status"],
108
+ },
102
109
  config: {
103
110
  category: "Agents",
104
111
  description: "Legacy model compatibility helpers; prefer `ouro use` and `ouro check`",
@@ -5,6 +5,39 @@
5
5
  * Pure functions: no side effects, no daemon communication.
6
6
  * Each command group has its own parser; parseOuroCommand dispatches.
7
7
  */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
8
41
  Object.defineProperty(exports, "__esModule", { value: true });
9
42
  exports.extractAgentFlag = extractAgentFlag;
10
43
  exports.extractFacingFlag = extractFacingFlag;
@@ -15,8 +48,11 @@ exports.inferAgentNameFromRemote = inferAgentNameFromRemote;
15
48
  exports.parseMcpServeCommand = parseMcpServeCommand;
16
49
  exports.parseAcpServeCommand = parseAcpServeCommand;
17
50
  exports.parseOuroCommand = parseOuroCommand;
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
18
53
  const friends_1 = require("@ouro.bot/friends");
19
54
  const cli_help_1 = require("./cli-help");
55
+ const local_audio_control_1 = require("../../senses/voice/local-audio-control");
20
56
  const cli_desk_1 = require("./cli-desk");
21
57
  const flight_recorder_1 = require("../../arc/flight-recorder");
22
58
  const core_1 = require("../../nerves/review/core");
@@ -31,6 +67,119 @@ function extractAgentFlag(args) {
31
67
  const rest = [...args.slice(0, idx), ...args.slice(idx + 2)];
32
68
  return { agent, rest };
33
69
  }
70
+ const VOICE_USAGE = "Usage: ouro voice join --agent <name> [--friend <id>] [--participants <text>] [--occasion <text>] [--mode conversation] | ouro voice leave --agent <name> | ouro voice status --agent <name>";
71
+ const VOICE_TEXT_FLAGS = {
72
+ "--friend": "friendId",
73
+ "--participants": "participants",
74
+ "--occasion": "occasion",
75
+ "--owner-name": "ownerName",
76
+ "--silent-consent": "silentConsent",
77
+ };
78
+ function parseVoicePositiveInt(value) {
79
+ if (!value || !/^[1-9]\d*$/.test(value))
80
+ throw new Error(VOICE_USAGE);
81
+ return Number(value);
82
+ }
83
+ /**
84
+ * File mode hands these paths to sox, which treats a bare `-` as standard input and `-d` or `-t`
85
+ * style words as devices and options. Only a real file path is allowed: absolute after resolving, a
86
+ * regular file for input, and a regular file (or new file) in an existing directory for output.
87
+ */
88
+ function validateVoiceFile(flag, value, kind) {
89
+ const fail = (why) => { throw new Error(`${flag} ${why}. ${VOICE_USAGE}`); };
90
+ if (value.startsWith("-"))
91
+ return fail("must be a regular file path, not a dash or option");
92
+ const resolved = path.resolve(value);
93
+ const describe = (file) => {
94
+ try {
95
+ return fs.statSync(file);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ };
101
+ const stat = describe(resolved);
102
+ if (kind === "input") {
103
+ if (!stat)
104
+ return fail(`file ${resolved} does not exist`);
105
+ if (!stat.isFile())
106
+ return fail(`must be a regular file, and ${resolved} is not`);
107
+ return resolved;
108
+ }
109
+ if (stat) {
110
+ if (!stat.isFile())
111
+ return fail(`must be a regular file, and ${resolved} is not (no devices or directories)`);
112
+ return resolved;
113
+ }
114
+ const parent = describe(path.dirname(resolved));
115
+ if (!parent?.isDirectory())
116
+ return fail(`needs an existing directory, and ${path.dirname(resolved)} is not one`);
117
+ return resolved;
118
+ }
119
+ /** `ouro voice join|leave|status`: the local audio lane. Device names are never arguments. */
120
+ function parseVoiceCommand(args) {
121
+ const [sub, ...flags] = args;
122
+ if (sub !== "join" && sub !== "leave" && sub !== "status")
123
+ throw new Error(VOICE_USAGE);
124
+ const request = { agentName: "" };
125
+ let inputPath;
126
+ let outputPath;
127
+ for (let i = 0; i < flags.length; i += 1) {
128
+ const flag = flags[i];
129
+ if (flag === "--owner-alone" && sub === "join") {
130
+ request.ownerAlone = true;
131
+ continue;
132
+ }
133
+ const value = flags[i + 1];
134
+ if (value === undefined || value.startsWith("--"))
135
+ throw new Error(VOICE_USAGE);
136
+ i += 1;
137
+ if (flag === "--agent") {
138
+ request.agentName = value;
139
+ }
140
+ else if (sub !== "join") {
141
+ throw new Error(VOICE_USAGE);
142
+ }
143
+ else if (flag in VOICE_TEXT_FLAGS) {
144
+ request[VOICE_TEXT_FLAGS[flag]] = value;
145
+ }
146
+ else if (flag === "--mode") {
147
+ if (value !== "conversation")
148
+ throw new Error(VOICE_USAGE);
149
+ request.mode = "conversation";
150
+ }
151
+ else if (flag === "--notify-session") {
152
+ const notify = (0, local_audio_control_1.parseNotifySession)(value);
153
+ if (!notify)
154
+ throw new Error(VOICE_USAGE);
155
+ request.notify = notify;
156
+ }
157
+ else if (flag === "--input-file") {
158
+ inputPath = validateVoiceFile(flag, value, "input");
159
+ }
160
+ else if (flag === "--output-file") {
161
+ outputPath = validateVoiceFile(flag, value, "output");
162
+ }
163
+ else if (flag === "--idle-silence-ms") {
164
+ request.idleSilenceMs = parseVoicePositiveInt(value);
165
+ }
166
+ else if (flag === "--max-duration-ms") {
167
+ request.maxDurationMs = parseVoicePositiveInt(value);
168
+ }
169
+ else {
170
+ throw new Error(VOICE_USAGE);
171
+ }
172
+ }
173
+ if (!request.agentName)
174
+ throw new Error(VOICE_USAGE);
175
+ if (sub !== "join")
176
+ return { kind: sub === "leave" ? "voice.leave" : "voice.status", agent: request.agentName };
177
+ if ((inputPath === undefined) !== (outputPath === undefined))
178
+ throw new Error(VOICE_USAGE);
179
+ if (inputPath !== undefined && outputPath !== undefined)
180
+ request.files = { inputPath, outputPath };
181
+ return { kind: "voice.join", request };
182
+ }
34
183
  function parseLogsCommand(args) {
35
184
  if (args.length === 0)
36
185
  return { kind: "daemon.logs" };
@@ -164,6 +313,9 @@ function usage() {
164
313
  " ouro a2a delegated-commands revoke --friend <id> [--agent <name>] (run as root)",
165
314
  " ouro a2a delegated-commands list [--json] [--agent <name>]",
166
315
  " ouro whoami [--agent <name>]",
316
+ " ouro voice join --agent <name> [--friend <id>] [--participants <text>] [--occasion <text>] [--mode conversation] [--owner-alone] [--owner-name <name>] [--silent-consent <statement>]",
317
+ " ouro voice leave --agent <name>",
318
+ " ouro voice status --agent <name>",
167
319
  " ouro session list [--agent <name>]",
168
320
  " ouro mcp list",
169
321
  " ouro mcp call <server> <tool> [--args '{...}']",
@@ -2892,6 +3044,8 @@ function parseOuroCommand(args) {
2892
3044
  return parseConfigCommand(args.slice(1));
2893
3045
  if (head === "mcp")
2894
3046
  return parseMcpCommand(args.slice(1));
3047
+ if (head === "voice")
3048
+ return parseVoiceCommand(args.slice(1));
2895
3049
  if (head === "whoami") {
2896
3050
  const { agent } = extractAgentFlag(args.slice(1));
2897
3051
  return { kind: "whoami", ...(agent ? { agent } : {}) };
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.voiceLocalAudioToolDefinition = void 0;
37
+ exports.localAudioToolGate = localAudioToolGate;
38
+ exports.ownerConsentsToSilentJoin = ownerConsentsToSilentJoin;
39
+ exports.createVoiceLocalAudioToolDefinition = createVoiceLocalAudioToolDefinition;
40
+ exports.defaultLocalAudioToolDeps = defaultLocalAudioToolDeps;
41
+ const path = __importStar(require("path"));
42
+ const identity_1 = require("../heart/identity");
43
+ const runtime_1 = require("../nerves/runtime");
44
+ /**
45
+ * The tool that lets a family-trusted owner ask the agent to join a call on this Mac through the
46
+ * BlackHole virtual devices. It has no device arguments: the lane always captures BlackHole 16ch
47
+ * and plays into BlackHole 2ch, so it can never listen to a physical microphone.
48
+ */
49
+ const TOOL_NAME = "voice_join_local_audio";
50
+ /** Direct (one person to one agent) text channels. Voice, inner, mail, a2a and mcp never get this tool. */
51
+ const DIRECT_TEXT_CHANNELS = new Set(["cli", "bluebubbles", "telegram"]);
52
+ const DEVICE_ARGUMENTS = new Set(["device", "input_device", "output_device", "microphone", "input", "output", "capture_device", "playback_device"]);
53
+ const ACTIONS = new Set(["join", "leave", "status"]);
54
+ /** One predicate for both the offer (tool selection) and the execution-time re-check. */
55
+ function localAudioToolGate(input) {
56
+ const context = input.context;
57
+ if (!context?.friend || !context.channel)
58
+ return { ok: false, reason: "no resolved friend for this session" };
59
+ if (input.habitSession || input.autonomousTurnKind || input.currentExternalEvent || input.delegatedCommand || input.relationshipAuthorization || input.voiceCall) {
60
+ return { ok: false, reason: "this turn is automatic, delegated, relationship-scoped or a live call, not a direct conversation" };
61
+ }
62
+ if (!DIRECT_TEXT_CHANNELS.has(context.channel.channel)) {
63
+ return { ok: false, reason: "it works only in a direct text session (cli, bluebubbles, telegram); Teams cannot confirm a one-to-one chat yet" };
64
+ }
65
+ if (context.isGroupChat)
66
+ return { ok: false, reason: "it is not available in a group chat" };
67
+ if (context.friend.trustLevel !== "family")
68
+ return { ok: false, reason: "only a family-trust friend can start it" };
69
+ return { ok: true };
70
+ }
71
+ const normalizeSentence = (text) => text.replace(/\s+/g, " ").trim().replace(/[.!?;:,\s]+$/u, "").toLowerCase();
72
+ const CONSENT_WORDING = /\b(consent(s|ed)?|agree[sd]?|okay with|ok with|fine with|permission)\b/iu;
73
+ const SILENT_WORDING = /\b(silent(ly)?|quiet(ly)?|no announcement|without (an |the )?announc\w*|don'?t announce|do not announce|skip the announcement)\b/iu;
74
+ const MIN_CONSENT_WORDS = 5;
75
+ /**
76
+ * True only when the statement is one whole sentence of the owner's current message, word for word,
77
+ * and that sentence both states consent and asks for a silent join. A fragment ("the call") or a
78
+ * sentence about something else never counts, so text the model was steered into quoting cannot
79
+ * turn an ordinary request into a covert recording.
80
+ */
81
+ function ownerConsentsToSilentJoin(message, statement) {
82
+ const wanted = normalizeSentence(statement);
83
+ if (wanted.split(" ").length < MIN_CONSENT_WORDS || !CONSENT_WORDING.test(wanted) || !SILENT_WORDING.test(wanted))
84
+ return false;
85
+ const sentences = (message ?? "").split(/(?<=[.!?])\s+|\n+/u).map(normalizeSentence);
86
+ return sentences.includes(wanted);
87
+ }
88
+ function readString(args, key) {
89
+ const value = args[key];
90
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
91
+ }
92
+ function createVoiceLocalAudioToolDefinition(deps) {
93
+ return {
94
+ tool: {
95
+ type: "function",
96
+ function: {
97
+ name: TOOL_NAME,
98
+ description: [
99
+ "join, leave, or check a call on this Mac through the BlackHole virtual audio devices (any call app: FaceTime, Zoom, Meet, a recorder).",
100
+ "The call app must use BlackHole 2ch as its microphone and BlackHole 16ch as its speaker. This tool never listens to a physical microphone and takes no device names.",
101
+ "Joining announces you aloud as an AI assistant that is transcribing. Skip the announcement only when the owner's current message has a sentence saying everyone consented to a silent join; pass that whole sentence in silent_consent exactly as they wrote it. A paraphrase or a fragment is refused.",
102
+ "Several people may share this audio, and you cannot tell voices apart. Use action=leave to hang up.",
103
+ ].join(" "),
104
+ parameters: {
105
+ type: "object",
106
+ properties: {
107
+ action: { type: "string", enum: ["join", "leave", "status"], description: "join (default), leave, or status" },
108
+ participants: { type: "string", description: "who is on the call, as the owner described them" },
109
+ occasion: { type: "string", description: "what the call is for" },
110
+ owner_alone: { type: "boolean", description: "true only when the owner said they are alone on the call" },
111
+ silent_consent: { type: "string", description: "the owner's own words consenting to a silent join (no spoken announcement), copied verbatim from their current message; omit otherwise" },
112
+ },
113
+ },
114
+ },
115
+ },
116
+ handler: async (args, ctx) => {
117
+ const gate = ctx ? localAudioToolGate(ctx) : { ok: false, reason: "no session context" };
118
+ if (!gate.ok)
119
+ return `${TOOL_NAME} is not available: ${gate.reason}.`;
120
+ const provided = Object.keys(args).find((key) => DEVICE_ARGUMENTS.has(key));
121
+ if (provided) {
122
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "tools", event: "tool.voice_local_audio_device_refused", message: "local audio tool refused a device argument", meta: { argument: provided } });
123
+ return "This tool only uses the BlackHole virtual devices and never listens to a physical microphone or any other device. Remove the device argument.";
124
+ }
125
+ const action = readString(args, "action") ?? "join";
126
+ if (!ACTIONS.has(action))
127
+ return "action must be join, leave, or status.";
128
+ const agentName = ctx?.agentName;
129
+ if (!agentName)
130
+ return `${TOOL_NAME} is not available: no agent name for this session.`;
131
+ if (action === "leave")
132
+ return deps.leave(agentName);
133
+ if (action === "status")
134
+ return deps.status(agentName);
135
+ const silentConsent = readString(args, "silent_consent");
136
+ if (silentConsent && !ownerConsentsToSilentJoin(ctx.currentUserMessage, silentConsent)) {
137
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "tools", event: "tool.voice_local_audio_silent_refused", message: "silent join refused: consent not in the owner's current message", meta: { agentName } });
138
+ return "I can't do a silent join from here: the consent has to be one whole sentence of the owner's current message, copied exactly, that says everyone consented to a silent join, and I can't confirm that. Join with the spoken announcement instead, or the owner can run `ouro voice join --silent-consent \"...\"` themselves.";
139
+ }
140
+ const friend = ctx.context.friend;
141
+ const channel = ctx.context.channel.channel;
142
+ const session = ctx.currentSession;
143
+ const notify = session
144
+ ? { friendId: session.friendId, channel: session.channel, key: session.key }
145
+ : channel === "cli" ? { friendId: friend.id, channel: "cli", key: "session" } : undefined;
146
+ const request = {
147
+ agentName,
148
+ friendId: friend.id,
149
+ mode: "conversation",
150
+ ownerName: friend.name,
151
+ ...(readString(args, "participants") ? { participants: readString(args, "participants") } : {}),
152
+ ...(readString(args, "occasion") ? { occasion: readString(args, "occasion") } : {}),
153
+ ...(String(args.owner_alone) === "true" ? { ownerAlone: true } : {}),
154
+ ...(silentConsent ? { silentConsent } : {}),
155
+ ...(notify ? { notify } : {}),
156
+ };
157
+ (0, runtime_1.emitNervesEvent)({
158
+ component: "tools",
159
+ event: "tool.voice_local_audio_join_requested",
160
+ message: "local audio join requested by the owner",
161
+ meta: { agentName, silent: String(Boolean(request.silentConsent)), ownerAlone: String(Boolean(request.ownerAlone)), notify: String(Boolean(notify)) },
162
+ });
163
+ const result = await deps.launch(request);
164
+ if (!result.ok)
165
+ return `I did not join the call: ${result.message}`;
166
+ return [
167
+ result.message,
168
+ request.silentConsent
169
+ ? "I joined silently on the owner's stated consent."
170
+ : "I am announcing myself aloud as an AI assistant that is transcribing; if I cannot confirm that was spoken I leave and tell the owner.",
171
+ "The call app must use BlackHole 2ch as its microphone and BlackHole 16ch as its speaker for me to hear and be heard.",
172
+ notify ? "The owner is notified when I join and when I leave." : "I could not notify the owner's session (no session to notify), so tell them yourself.",
173
+ ].join(" ");
174
+ },
175
+ };
176
+ }
177
+ /** Loaded lazily: the join runner pulls in the whole Realtime session, which itself imports the tool registry. */
178
+ function defaultLocalAudioToolDeps(options = {}) {
179
+ const agentRoot = options.agentRoot ?? ((agentName) => (0, identity_1.getAgentRoot)(agentName));
180
+ return {
181
+ launch: async (request) => {
182
+ const join = await Promise.resolve().then(() => __importStar(require("../senses/voice/local-audio-join")));
183
+ const control = await Promise.resolve().then(() => __importStar(require("../senses/voice/local-audio-control")));
184
+ const launch = options.launch ?? {
185
+ execPath: process.execPath,
186
+ cliEntry: path.resolve(__dirname, "../heart/daemon/ouro-entry.js"),
187
+ spawn: join.defaultLaunchSpawn(control.localAudioPaths(agentRoot(request.agentName))),
188
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
189
+ now: Date.now,
190
+ timeoutMs: 300_000,
191
+ };
192
+ return join.launchLocalAudioJoin(request, { agentRoot, ...launch });
193
+ },
194
+ leave: async (agentName) => (await Promise.resolve().then(() => __importStar(require("../senses/voice/local-audio-join")))).runLocalAudioLeave(agentName, { agentRoot }),
195
+ status: async (agentName) => (await Promise.resolve().then(() => __importStar(require("../senses/voice/local-audio-join")))).runLocalAudioStatus(agentName, { agentRoot }),
196
+ };
197
+ }
198
+ exports.voiceLocalAudioToolDefinition = createVoiceLocalAudioToolDefinition(defaultLocalAudioToolDeps());
@@ -40,6 +40,7 @@ const tools_house_care_1 = require("./tools-house-care");
40
40
  const steward_policy_1 = require("../heart/steward-policy");
41
41
  const relationship_authorization_1 = require("./relationship-authorization");
42
42
  const tool_approval_1 = require("../heart/tool-approval");
43
+ const tools_voice_local_1 = require("./tools-voice-local");
43
44
  const tools_sanctuary_host_1 = require("./tools-sanctuary-host");
44
45
  function safeGetAgentRoot() {
45
46
  try {
@@ -172,6 +173,7 @@ function selectToolsForChannel(capabilities, toolPreferences, _context, provider
172
173
  ...tools_base_1.baseToolDefinitions,
173
174
  ...(capabilities?.channel === "bluebubbles" ? tools_bluebubbles_1.bluebubblesToolDefinitions : []),
174
175
  ...(capabilities?.channel === "voice" ? tools_voice_1.voiceToolDefinitions : []),
176
+ ...((0, tools_voice_local_1.localAudioToolGate)({ ...context, context: _context }).ok ? [tools_voice_local_1.voiceLocalAudioToolDefinition] : []),
175
177
  ...integrations, ...mcp,
176
178
  ];
177
179
  }
@@ -139,7 +139,9 @@ class PendingVoiceCalls {
139
139
  this.maxEntries = options.maxEntries ?? exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES;
140
140
  this.now = options.now ?? Date.now;
141
141
  }
142
- record(identity, nonce) {
142
+ record(candidate, nonce) {
143
+ // The network registry never carries local-audio identity; only the local transport sets it.
144
+ const { local: _local, ...identity } = candidate;
143
145
  const existing = this.live(identity.callSid);
144
146
  const nonces = existing?.nonces ?? new Set();
145
147
  nonces.add(nonce);