oomi-ai 0.10.1 → 0.11.0

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.
@@ -142,6 +142,10 @@ Use this when your owner asks you to let another agent (Claude, Codex, a Grok bo
142
142
 
143
143
  To review a channel, use `oomi channel list` and `oomi channel show ID`.
144
144
 
145
+ **When you can't answer.** Your AI provider might reject your sign-in, hit a limit or be unreachable. If so, oomi records the reason, tells your owner what to fix and tells the other side the message wasn't answered. Tell your owner plainly which AI provider failed and what it said. Ask them to sign in again on this computer themselves, in a terminal or the provider's app here. Never ask them to paste passwords or one-time codes into a chat. `oomi verify --connection NAME` checks that you can answer again. `oomi channel create` also returns `warnings` when your bridge can't answer; tell your owner before they share the link.
146
+
147
+ **Use your own connection.** Turns started by the bridge already use it. Otherwise, pass `--connection NAME` (`oomi bridge list` shows the names). If oomi says it used a connection that isn't linked, rerun with your own. Don't create a new Auth Link unless your owner asked to connect you.
148
+
145
149
  To join another agent's link as a guest:
146
150
 
147
151
  1. Run `oomi channel join LINK`.
package/bin/oomi-ai.js CHANGED
@@ -12,6 +12,7 @@ import { lookup as dnsLookup } from 'dns/promises';
12
12
  import { fileURLToPath } from 'url';
13
13
  import WebSocket from 'ws';
14
14
  import { planPackageUpdate, isWorkerDescendant, queueRestart, readMaintenance, createMaintenanceController } from '../lib/bridgeMaintenance.js';
15
+ import { processCommand, readProcessTable } from '../lib/processTable.js';
15
16
  import { bridgeServiceConfig, createBridgeService, renderLaunchAgent } from '../lib/bridgeService.js';
16
17
  import { LOCAL_PROTOCOL_MIN, LOCAL_PROTOCOL_MAX, probeGateway } from '../lib/gatewayProbe.js';
17
18
  import { LocalAgentRuntime, localAgentConfig, runLocalAgent, writePrivateJson, startChannelWorker } from '../lib/localAgent.js';
@@ -20,6 +21,7 @@ import { handleSpacesCommand, compactHealth } from '../lib/spaces.js';
20
21
  import { handleObjectivesCommand } from '../lib/objectives.js';
21
22
  import { handleRoutinesCommand, startRoutineEventWorker } from '../lib/routines.js';
22
23
  import { handleChannelCommand, startAgentChannelWorker } from '../lib/agentChannels.js';
24
+ import { classifyRuntimeFailure } from '../lib/runtimeFailure.js';
23
25
  import { createPersonaApiClient } from '../lib/personaApiClient.js';
24
26
  import { inferSpokenMetadataFromContent, normalizeSpokenMetadata } from '../lib/spokenMetadata.js';
25
27
  import {
@@ -1050,17 +1052,10 @@ function isBridgeProcess(pid) {
1050
1052
  if (!normalized) return false;
1051
1053
  if (!isPidAlive(normalized)) return false;
1052
1054
 
1053
- try {
1054
- const result = spawnSync('ps', ['-p', String(normalized), '-o', 'command='], {
1055
- encoding: 'utf8',
1056
- stdio: ['ignore', 'pipe', 'ignore'],
1057
- });
1058
- const command = String(result.stdout || '').trim();
1059
- if (!command) return true;
1060
- return isBridgeWorkerCommand(command);
1061
- } catch {
1062
- return true;
1063
- }
1055
+ // A live pid whose command the host won't say is taken as the bridge (it holds the lock).
1056
+ const command = processCommand(normalized);
1057
+ if (!command) return true;
1058
+ return isBridgeWorkerCommand(command);
1064
1059
  }
1065
1060
 
1066
1061
  function readBridgeLock() {
@@ -1149,6 +1144,23 @@ function acquireBridgeLock(deviceId) {
1149
1144
  return release;
1150
1145
  }
1151
1146
 
1147
+ /**
1148
+ * Tells Oomi which models the runtime offers, so the persona's page can offer a faster one for
1149
+ * calls (Voice::CallModel). False when it didn't arrive, so the next turn reports again.
1150
+ */
1151
+ async function reportRuntimeModels({ brokerHttp, deviceToken }, report, fetchImpl = fetch) {
1152
+ if (!brokerHttp || !deviceToken) return false;
1153
+ try {
1154
+ const response = await fetchImpl(`${brokerHttp.replace(/\/$/, '')}/v1/agent_runtime/models`, {
1155
+ method: 'PUT',
1156
+ headers: { Authorization: `Bearer ${deviceToken}`, 'Content-Type': 'application/json' },
1157
+ body: JSON.stringify(report),
1158
+ signal: AbortSignal.timeout(10000),
1159
+ });
1160
+ return response.ok;
1161
+ } catch { return false; }
1162
+ }
1163
+
1152
1164
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
1153
1165
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
1154
1166
  method: 'POST',
@@ -1389,6 +1401,12 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
1389
1401
  if (Number.isFinite(rawParams.timeoutMs) && rawParams.timeoutMs > 0) {
1390
1402
  sanitized.timeoutMs = Math.floor(rawParams.timeoutMs);
1391
1403
  }
1404
+ // A call turn's faster model, for the runtimes Oomi drives itself (OpenClaw's gateway
1405
+ // would reject the field).
1406
+ const callModel = rawParams.oomi?.call?.model;
1407
+ if (gatewayAuth?.gatewayUrl === 'local-agent:' && typeof callModel === 'string' && callModel.trim() && callModel.trim().length <= 200) {
1408
+ sanitized.oomi = { call: { model: callModel.trim() } };
1409
+ }
1392
1410
 
1393
1411
  const idempotencyKeyCandidates = [
1394
1412
  rawParams.idempotencyKey,
@@ -1632,6 +1650,13 @@ function shouldSuppressOomiVisibleText(value) {
1632
1650
  return !sanitizeUserVisibleText(text);
1633
1651
  }
1634
1652
 
1653
+ // oomi's notes to the persona, sent as the person's turn because runtimes take nothing else
1654
+ // (a call's "they're calling you, pick up" cue): never the person's words on any screen.
1655
+ const OOMI_CUE_PREFIX = '[oomi:cue:';
1656
+ // What this bridge does for oomi, said on each connect (the backend greets a call only through
1657
+ // a bridge that keeps the cue out of the conversation).
1658
+ const OOMI_BRIDGE_CAPABILITIES = ['call.greeting.v1'];
1659
+
1635
1660
  function sanitizeGatewayMessageForOomi(message) {
1636
1661
  if (!message || typeof message !== 'object') return null;
1637
1662
 
@@ -1641,6 +1666,9 @@ function sanitizeGatewayMessageForOomi(message) {
1641
1666
  }
1642
1667
 
1643
1668
  const rawText = extractTextFromGatewayMessage(message);
1669
+ if ((role === 'user' || !role) && typeof rawText === 'string' && rawText.includes(OOMI_CUE_PREFIX)) {
1670
+ return null;
1671
+ }
1644
1672
  if (shouldSuppressOomiVisibleText(rawText)) {
1645
1673
  return null;
1646
1674
  }
@@ -1713,6 +1741,17 @@ function sanitizeGatewayFrameForOomiClient(frameText) {
1713
1741
  return { frameText, changed: false, suppressed: false };
1714
1742
  }
1715
1743
 
1744
+ // The runtime accepted oomi's connect: say what this bridge can do. Grok Bot gets no greeting (its
1745
+ // inbox is slow, and Grok's own app would show the cue).
1746
+ function annotateConnectResponseForOomi(frameText, { runtime = 'openclaw', version = '' } = {}) {
1747
+ const frame = parseJsonPayload(frameText);
1748
+ if (!frame || typeof frame !== 'object' || frame.type !== 'res' || !frame.ok) return frameText;
1749
+ const payload = frame.payload && typeof frame.payload === 'object' && !Array.isArray(frame.payload) ? frame.payload : {};
1750
+ const existing = payload.oomi && typeof payload.oomi === 'object' && !Array.isArray(payload.oomi) ? payload.oomi : {};
1751
+ const capabilities = runtime === 'grokbot' ? [] : [...OOMI_BRIDGE_CAPABILITIES];
1752
+ return JSON.stringify({ ...frame, payload: { ...payload, oomi: { ...existing, bridgeVersion: version, capabilities } } });
1753
+ }
1754
+
1716
1755
  function summarizeVoiceFrameContract(frameText) {
1717
1756
  const frame = parseJsonPayload(frameText);
1718
1757
  if (!frame || typeof frame !== 'object') {
@@ -2375,24 +2414,9 @@ function listBridgeProcessPids() {
2375
2414
  // custom connections directory is used. Never sweep another installation.
2376
2415
  if (bridgeConnectionName) return Array.from(pids);
2377
2416
 
2378
- try {
2379
- const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
2380
- encoding: 'utf8',
2381
- stdio: ['ignore', 'pipe', 'ignore'],
2382
- });
2383
- const output = String(result.stdout || '');
2384
- for (const rawLine of output.split('\n')) {
2385
- const line = rawLine.trim();
2386
- if (!line) continue;
2387
- const match = line.match(/^(\d+)\s+(.+)$/);
2388
- if (!match) continue;
2389
- const pid = Number(match[1]);
2390
- const command = String(match[2] || '');
2391
- if (!isBridgeWorkerCommand(command)) continue;
2392
- addPid(pid);
2393
- }
2394
- } catch {
2395
- // best-effort process scan
2417
+ // A best-effort scan of the host's processes.
2418
+ for (const row of readProcessTable() || []) {
2419
+ if (isBridgeWorkerCommand(row.command)) addPid(row.pid);
2396
2420
  }
2397
2421
 
2398
2422
  return Array.from(pids).sort((a, b) => a - b);
@@ -2552,9 +2576,11 @@ async function startOpenclawBridge(flags) {
2552
2576
  console.log(`Claimed bridge device token for ${deviceId}.`);
2553
2577
  }
2554
2578
 
2579
+ const bridgeRuntimeName = bridgeState.localAgent?.runtime || bridgeState.runtime || 'openclaw';
2555
2580
  const localRuntime = bridgeState.localAgent
2556
2581
  ? new LocalAgentRuntime({ ...bridgeState.localAgent, connection: bridgeConnectionName }, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2557
2582
  : null;
2583
+ if (localRuntime) localRuntime.onModels = (report) => reportRuntimeModels({ brokerHttp, deviceToken }, report);
2558
2584
  localRuntime?.warm();
2559
2585
  const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
2560
2586
  if (!localRuntime && !gateway.token && !gateway.password) {
@@ -2650,7 +2676,9 @@ async function startOpenclawBridge(flags) {
2650
2676
  state: { ...bridgeState, brokerHttp, deviceToken },
2651
2677
  connection: bridgeConnectionName,
2652
2678
  deliver: deliverAgentEvent,
2653
- onError: (error) => updateBridgeStatus({ agentChannelError: `Agent channel delivery: ${error.message}` }),
2679
+ onError: (error) => updateBridgeStatus({ agentChannelError: error.failure
2680
+ ? `Couldn't answer a chat-link message: ${error.failure.summary}. ${error.failure.hint}`
2681
+ : `Agent channel delivery: ${error.message}` }),
2654
2682
  onDelivered: () => updateBridgeStatus({ agentChannelError: '' }),
2655
2683
  });
2656
2684
  console.log(`Local gateway: ${gateway.gatewayUrl}`);
@@ -3242,6 +3270,7 @@ async function startOpenclawBridge(flags) {
3242
3270
 
3243
3271
  gatewaySocket.on('message', runBridgeCallbackSafely((gatewayRaw) => {
3244
3272
  let frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
3273
+ let connectAccepted = false;
3245
3274
  const visibleSanitized = sanitizeGatewayFrameForOomiClient(frame);
3246
3275
  if (visibleSanitized.changed) {
3247
3276
  frame = visibleSanitized.frameText;
@@ -3301,6 +3330,7 @@ async function startOpenclawBridge(flags) {
3301
3330
  stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
3302
3331
  });
3303
3332
  if (requestMeta.method === 'connect' && responseMeta.ok) {
3333
+ connectAccepted = true;
3304
3334
  const releasedFrames = flushWaitingForConnect(sessionBridge);
3305
3335
  for (const released of releasedFrames) {
3306
3336
  const releasedMeta = extractGatewayRequestMeta(released.frameText);
@@ -3409,6 +3439,9 @@ async function startOpenclawBridge(flags) {
3409
3439
  });
3410
3440
  }
3411
3441
 
3442
+ if (connectAccepted) {
3443
+ frame = annotateConnectResponseForOomi(frame, { runtime: bridgeRuntimeName, version: currentPackageVersion() });
3444
+ }
3412
3445
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
3413
3446
  }, (error) => {
3414
3447
  reportBridgeRuntimeFault({ phase: 'gateway.message', sessionId, error });
@@ -4450,6 +4483,14 @@ function connectionDirectory() {
4450
4483
  return path.join(process.env.OOMI_CONNECTIONS_DIR || path.join(os.homedir(), '.oomi', 'connections'), bridgeConnectionName);
4451
4484
  }
4452
4485
 
4486
+ // A failed connection check usually means the agent's own AI access is broken.
4487
+ // Say that plainly, with the fix, instead of a bare runtime error.
4488
+ function explainVerificationFailure(err) {
4489
+ const failure = classifyRuntimeFailure(err);
4490
+ if (failure.code === 'runtime_failed') return err;
4491
+ return Object.assign(new Error(`This agent couldn't answer the connection check: ${failure.summary}. ${failure.hint} Oomi itself is fine; nothing else needs reconnecting. (Details: ${failure.detail})`), { failure });
4492
+ }
4493
+
4453
4494
  async function verifyLocalAgent(state) {
4454
4495
  if (state.openclawHome) process.env.OPENCLAW_HOME = state.openclawHome;
4455
4496
  let result;
@@ -4519,8 +4560,9 @@ async function connectLocalAgent(flags) {
4519
4560
  const verified = await verifyLocalAgent(state);
4520
4561
  state.verifiedAt = verified.verifiedAt;
4521
4562
  } catch (err) {
4522
- updateBridgeStatus({ runtime, runtimeReady: false, status: 'degraded', lastErrorMessage: err.message });
4523
- throw err;
4563
+ const explained = explainVerificationFailure(err);
4564
+ updateBridgeStatus({ runtime, runtimeReady: false, status: 'degraded', lastErrorMessage: explained.message });
4565
+ throw explained;
4524
4566
  }
4525
4567
  } else {
4526
4568
  console.log(`Reusing the verified ${runtime} runtime. No model test message sent. Use oomi verify for an explicit paid-model probe.`);
@@ -4676,8 +4718,9 @@ async function main() {
4676
4718
  updateBridgeStatus({ runtimeReady: true, verifiedAt: result.verifiedAt, lastErrorMessage: '' });
4677
4719
  console.log(JSON.stringify(result, null, 2));
4678
4720
  } catch (err) {
4679
- updateBridgeStatus({ runtimeReady: false, status: 'degraded', lastErrorMessage: err.message });
4680
- throw err;
4721
+ const explained = explainVerificationFailure(err);
4722
+ updateBridgeStatus({ runtimeReady: false, status: 'degraded', lastErrorMessage: explained.message });
4723
+ throw explained;
4681
4724
  }
4682
4725
  return;
4683
4726
  }
@@ -4828,6 +4871,7 @@ if (__isDirectExecution) {
4828
4871
  }
4829
4872
 
4830
4873
  export {
4874
+ annotateConnectResponseForOomi,
4831
4875
  prepareGatewayFrameForLocalGateway,
4832
4876
  sanitizeGatewayFrameForOomiClient,
4833
4877
  sanitizeUserVisibleText,
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { readJson, writePrivateJson } from './localAgent.js';
6
+ import { classifyRuntimeFailure } from './runtimeFailure.js';
5
7
 
6
8
  // Owner-visible conversations between this agent and one other agent.
7
9
  // Host side uses the device token against the Oomi API. Guest side (after
@@ -42,6 +44,46 @@ export function parseChannelLink(link) {
42
44
  return { origin: url.origin, token: match[1] };
43
45
  }
44
46
 
47
+ function connectionsRoot() {
48
+ return process.env.OOMI_CONNECTIONS_DIR || path.join(os.homedir(), '.oomi', 'connections');
49
+ }
50
+
51
+ function localConnections() {
52
+ try {
53
+ return fs.readdirSync(connectionsRoot(), { withFileTypes: true })
54
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(connectionsRoot(), entry.name, 'oomi-bridge.json')))
55
+ .map((entry) => entry.name);
56
+ } catch { return []; }
57
+ }
58
+
59
+ // Agents that ran a command without --connection were sent to a stale default
60
+ // connection and then created Auth Links for it. Say which connection was used
61
+ // and which others exist, and steer away from reconnecting.
62
+ export function explainConnectionError(error, directory) {
63
+ const notLinked = error?.status === 403 && /not linked/i.test(error.message || '');
64
+ const missing = /Connect this agent to Oomi first/.test(error?.message || '');
65
+ if (!notLinked && !missing) return error;
66
+ const named = directory && path.resolve(path.dirname(directory)) === path.resolve(connectionsRoot());
67
+ const label = named ? `the connection "${path.basename(directory)}"` : 'the default OpenClaw connection';
68
+ const others = localConnections().filter((name) => !named || name !== path.basename(directory));
69
+ const problem = missing ? `found no saved login for ${label}` : `used ${label}, but that connection isn't linked to an oomi account`;
70
+ const next = others.length
71
+ ? `Connections on this computer: ${others.join(', ')}. If one of them is you, rerun with --connection NAME.`
72
+ : 'No other oomi connections were found on this computer.';
73
+ return Object.assign(new Error(`oomi ${problem}. ${next} Don't create a new Auth Link or reconnect unless your owner asked to connect this agent.`), { status: error.status, reasonCode: error.reasonCode });
74
+ }
75
+
76
+ // A link only helps if this agent can answer; warn before handing one out.
77
+ export function bridgeWarnings(directory) {
78
+ if (!directory) return [];
79
+ const status = readJson(path.join(directory, 'oomi-bridge-status.json'), null);
80
+ if (!status) return ['No oomi bridge status was found for this connection. Run oomi bridge repair for it, or guest messages will wait unanswered.'];
81
+ const warnings = [];
82
+ if (['error', 'degraded', 'failed', 'stopped'].includes(status.status)) warnings.push(`Your oomi bridge reports "${status.status}"${status.lastErrorMessage ? `: ${String(status.lastErrorMessage).replace(/\.\s*$/, '')}` : ''}.`);
83
+ if (status.agentChannelError) warnings.push(status.agentChannelError);
84
+ return warnings;
85
+ }
86
+
45
87
  function guestRecordPath(directory, channelId) {
46
88
  return path.join(directory, 'agent-channels', `${channelId}.json`);
47
89
  }
@@ -90,7 +132,10 @@ function optionalInteger(value, name) {
90
132
  return number;
91
133
  }
92
134
 
93
- export async function handleChannelCommand(state, action, flags = {}, positionals = [], { directory, request = channelRequest, guest = guestRequest, print = (value) => console.log(JSON.stringify(value, null, 2)) } = {}) {
135
+ export async function handleChannelCommand(state, action, flags = {}, positionals = [], { directory, request: rawRequest = channelRequest, guest = guestRequest, print = (value) => console.log(JSON.stringify(value, null, 2)) } = {}) {
136
+ const request = async (...args) => {
137
+ try { return await rawRequest(...args); } catch (error) { throw explainConnectionError(error, directory); }
138
+ };
94
139
  const id = positionals[0] || flags.id;
95
140
  const requireId = () => { if (typeof id !== 'string' || !CHANNEL_ID.test(id)) throw new Error('Provide a channel ID (ach_...) from oomi channel list.'); };
96
141
  let result;
@@ -111,6 +156,8 @@ export async function handleChannelCommand(state, action, flags = {}, positional
111
156
  result = { ...result, next: audience === 'person'
112
157
  ? 'Give your owner both links: the chat link to send to the person, and the watchUrl where they read along, approve requests and end the chat. The person\'s messages arrive as separate Oomi chat turns; your reply text goes back to them.'
113
158
  : 'Give this link to your owner to pass to the other agent. That agent joins from wherever it runs; do not start, install, sign in to or message it yourself, and never ask your owner for sign-in codes for it. Its messages arrive as separate Oomi channel turns; your reply text goes back to it. Your owner can watch and end the channel in Oomi.' };
159
+ const warnings = bridgeWarnings(directory);
160
+ if (warnings.length) result.warnings = [...warnings, 'Tell your owner about these before they share the link: the other side\'s messages will not be answered until they are fixed.'];
114
161
  break;
115
162
  }
116
163
  case 'list': {
@@ -244,8 +291,10 @@ export function startAgentChannelWorker({ state, deliver, connection = '', reque
244
291
  try {
245
292
  reply = await deliver({ sessionKey: item.sessionKey, idempotencyKey: item.messageId, message: channelTurnMessage(item, connection), retryInterrupted: false });
246
293
  } catch (error) {
247
- await request(state, `agent_channels/inbox/${item.messageId}/ack`, { method: 'POST', body: { outcome: 'failed', failureCode: 'host_turn_failed' } }).catch(() => {});
248
- onError(error);
294
+ // Report why, so the owner is told what to fix and the guest isn't left guessing.
295
+ const failure = classifyRuntimeFailure(error);
296
+ await request(state, `agent_channels/inbox/${item.messageId}/ack`, { method: 'POST', body: { outcome: 'failed', failureCode: failure.code, failureDetail: failure.detail } }).catch(() => {});
297
+ onError(Object.assign(error, { failure }));
249
298
  continue;
250
299
  }
251
300
  const text = typeof reply?.text === 'string' ? reply.text.trim() : '';
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { readJson, writePrivateJson } from './localAgent.js';
5
+ import { readProcessTable } from './processTable.js';
5
6
 
6
7
  export const maintenancePath = (directory) => path.join(directory, 'oomi-bridge-maintenance.json');
7
8
  export const readMaintenance = (directory) => readJson(maintenancePath(directory), null);
@@ -19,11 +20,11 @@ export function planPackageUpdate(current, latest) {
19
20
  : { instruction: 'Already current. Do not reinstall or restart. Check oomi status only if the user also reports a connection problem.' }) };
20
21
  }
21
22
 
22
- export function isWorkerDescendant(workerPid, { pid = process.pid, run = spawnSync } = {}) {
23
+ export function isWorkerDescendant(workerPid, { pid = process.pid, run = spawnSync, platform = process.platform } = {}) {
23
24
  if (!Number.isInteger(workerPid) || workerPid <= 0) return false;
24
- const result = run('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 5000 });
25
- if (result.status !== 0) throw new Error('Cannot establish whether this command would stop its own agent. Run maintenance from an independent host terminal.');
26
- const parents = new Map(String(result.stdout).trim().split('\n').map((line) => line.trim().split(/\s+/).map(Number)));
25
+ const table = readProcessTable({ run, platform });
26
+ if (!table) throw new Error('Cannot establish whether this command would stop its own agent. Run maintenance from an independent host terminal.');
27
+ const parents = new Map(table.map((row) => [row.pid, row.ppid]));
27
28
  const visited = new Set();
28
29
  while (pid > 1 && !visited.has(pid)) {
29
30
  if (pid === workerPid) return true;
@@ -1,5 +1,6 @@
1
1
  import WebSocket from 'ws';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { redactDetail } from './runtimeFailure.js';
3
4
 
4
5
  // The broker's protocol is independent of the installed local gateway version.
5
6
  export const LOCAL_PROTOCOL_MIN = 3;
@@ -48,7 +49,11 @@ export function probeGateway({ gateway, deviceIdentity, prepareFrame, verifyRepl
48
49
  message: turn?.message || 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
49
50
  } }));
50
51
  } else if (verifyReply && frame.event === 'chat' && frame.payload?.runId === runId) {
51
- if (frame.payload.state === 'error' || frame.payload.state === 'aborted') return finish(new Error('Local agent could not complete the verification message.'));
52
+ if (frame.payload.state === 'error' || frame.payload.state === 'aborted') {
53
+ // Keep the runtime's own reason (for example a rejected model login) so callers can say what went wrong.
54
+ const reason = redactDetail(frame.payload.errorMessage || frame.payload.error?.message || frame.payload.state);
55
+ return finish(Object.assign(new Error(`Local agent could not complete the message: ${reason}`), { runtimeDetail: reason }));
56
+ }
52
57
  if (frame.payload.state === 'final') {
53
58
  const content = frame.payload.message?.content;
54
59
  const text = typeof content === 'string' ? content : (Array.isArray(content) ? content.map((part) => part.text || '').join('') : '');
package/lib/hermesAcp.js CHANGED
@@ -22,6 +22,9 @@ const IMAGE_TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'imag
22
22
  const SEED_MESSAGES = 12;
23
23
  const SEED_MESSAGE_CHARS = 800;
24
24
  const SEED_TOTAL_CHARS = 6000;
25
+ const MAX_REPORTED_MODELS = 60;
26
+ // Not models to talk with: images, video, speech, embeddings.
27
+ const NOT_CHAT = /imagine|image|video|stt|tts|speech|audio|embed|whisper|moderation/i;
25
28
 
26
29
  // Only fixed messages cross the bridge; Hermes diagnostics may hold credentials or tool output.
27
30
  export class HermesAcpError extends Error {
@@ -76,10 +79,42 @@ class HermesAcpClient {
76
79
  this.turns = new Map();
77
80
  this.loaded = new Set();
78
81
  this.replayed = new Map();
82
+ // The model each ACP session is on, and what Hermes offers (session/new and session/load
83
+ // answers), for a call's faster model (Oomi's call.model, Voice::CallModel on the server).
84
+ this.sessionModels = new Map();
85
+ this.availableModels = [];
86
+ this.newSessionModel = '';
79
87
  this.closed = false;
80
88
  this.ready = null;
81
89
  }
82
90
 
91
+ noteModels(sessionId, models, { created = false } = {}) {
92
+ if (!models || typeof models !== 'object') return;
93
+ const current = typeof models.currentModelId === 'string' ? models.currentModelId : '';
94
+ if (current) {
95
+ this.sessionModels.set(sessionId, current);
96
+ if (created) this.newSessionModel = current;
97
+ }
98
+ if (Array.isArray(models.availableModels)) {
99
+ this.availableModels = models.availableModels
100
+ .filter((model) => typeof model?.modelId === 'string' && model.modelId)
101
+ .map((model) => ({ id: model.modelId, name: typeof model.name === 'string' ? model.name : '' }));
102
+ }
103
+ }
104
+
105
+ /** Switches a session's model between turns (Hermes refuses mid-turn); false if it couldn't. */
106
+ async switchModel(sessionId, modelId) {
107
+ if (!modelId || this.turns.has(sessionId)) return false;
108
+ try {
109
+ await this.request('session/set_model', { sessionId, modelId });
110
+ this.sessionModels.set(sessionId, modelId);
111
+ return true;
112
+ } catch (err) {
113
+ if (err.code === 'hermes_acp_exited') throw err;
114
+ return false;
115
+ }
116
+ }
117
+
83
118
  start() {
84
119
  if (this.ready) return this.ready;
85
120
  this.ready = this.spawnAndInitialize().catch((err) => {
@@ -204,7 +239,8 @@ class HermesAcpClient {
204
239
  // counts as found only when its history is replayed.
205
240
  this.replayed.set(sessionId, 0);
206
241
  try {
207
- await this.request('session/load', { sessionId, cwd, mcpServers: [] });
242
+ const loaded = await this.request('session/load', { sessionId, cwd, mcpServers: [] });
243
+ this.noteModels(sessionId, loaded?.models);
208
244
  if (this.replayed.get(sessionId) > 0) {
209
245
  await this.prepare(sessionId);
210
246
  return { sessionId, created: false };
@@ -216,6 +252,7 @@ class HermesAcpClient {
216
252
  if (!create) return null;
217
253
  const created = await this.request('session/new', { cwd, mcpServers: [] });
218
254
  if (typeof created?.sessionId !== 'string' || !created.sessionId) throw unavailable();
255
+ this.noteModels(created.sessionId, created.models, { created: true });
219
256
  await this.prepare(created.sessionId);
220
257
  return { sessionId: created.sessionId, created: true };
221
258
  }
@@ -324,6 +361,8 @@ export function continuityPreamble(history) {
324
361
  for (const message of recent) {
325
362
  const words = textOf(message?.content).trim();
326
363
  if (!words || !['user', 'assistant'].includes(message?.role)) continue;
364
+ // oomi's own notes (a call's pick-up cue) aren't part of the thread to carry over.
365
+ if (message.role === 'user' && words.includes('[oomi:cue:')) continue;
327
366
  const line = `${message.role === 'user' ? 'User' : 'You'}: ${words.length > SEED_MESSAGE_CHARS ? `${words.slice(0, SEED_MESSAGE_CHARS)}…` : words}`;
328
367
  total += line.length;
329
368
  if (total > SEED_TOTAL_CHARS) break;
@@ -360,13 +399,87 @@ export async function runHermesAcpTurn(config, request, { signal, timeoutMs = 18
360
399
  const continuing = request.runtimeTransport === 'acp' ? request.runtimeSessionId : '';
361
400
  let { sessionId, created } = await client.openSession(continuing);
362
401
  const seed = () => continuityPreamble(request.history);
402
+ let chatModel = await applyCallModel(client, sessionId, request);
363
403
  let text;
364
404
  try {
365
405
  text = await client.prompt(sessionId, promptBlocks(request, created ? seed() : ''), { signal, timeoutMs, onText });
366
406
  } catch (err) {
367
407
  if (err.code !== 'hermes_session_unknown' || created) throw err;
368
408
  ({ sessionId } = await client.openSession(''));
409
+ // A fresh session starts on the chat model.
410
+ chatModel = await applyCallModel(client, sessionId, { ...request, chatModel: null });
369
411
  text = await client.prompt(sessionId, promptBlocks(request, seed()), { signal, timeoutMs, onText });
370
412
  }
371
- return { text, sessionId, transport: 'acp' };
413
+ return { text, sessionId, transport: 'acp', chatModel };
414
+ }
415
+
416
+ /**
417
+ * A call turn asks for the persona's faster model (request.callModel); every other turn puts
418
+ * the session back on the model it chats with. Returns the model to go back to, while the
419
+ * session is on the call model (kept in the conversation's state, so a restart still restores
420
+ * it), else null. A session whose model Hermes didn't say isn't switched: there'd be no way back.
421
+ */
422
+ async function applyCallModel(client, sessionId, request) {
423
+ const current = client.sessionModels.get(sessionId) || '';
424
+ const remembered = typeof request.chatModel === 'string' && request.chatModel ? request.chatModel : null;
425
+ const callModel = typeof request.callModel === 'string' && request.callModel ? request.callModel : '';
426
+ if (callModel) {
427
+ if (current === callModel) return remembered;
428
+ if (!current) return remembered;
429
+ const back = remembered || current;
430
+ return (await client.switchModel(sessionId, callModel)) ? back : remembered;
431
+ }
432
+ if (remembered && current !== remembered) return (await client.switchModel(sessionId, remembered)) ? null : remembered;
433
+ return null;
372
434
  }
435
+
436
+ /**
437
+ * What Hermes offers for a faster call model, for Oomi's persona page: the models of the provider
438
+ * the persona chats with (chatModel, or the one new sessions start on), a recommendation (one
439
+ * that doesn't reason first), and whether this runtime can switch. Null until Hermes has said.
440
+ */
441
+ export function hermesAcpModelReport(config, { chatModel = '' } = {}) {
442
+ const client = clients.get(clientKey(config));
443
+ if (!client || !client.availableModels.length) return null;
444
+ const current = chatModel || client.newSessionModel || [...client.sessionModels.values()][0] || '';
445
+ if (!current) return null;
446
+ const provider = current.includes(':') ? current.slice(0, current.indexOf(':') + 1) : '';
447
+ const available = client.availableModels
448
+ .filter((model) => (!provider || model.id.startsWith(provider)) && !NOT_CHAT.test(model.id))
449
+ .slice(0, MAX_REPORTED_MODELS)
450
+ .map((model) => ({ id: model.id, name: modelDisplayName(model.id) }));
451
+ if (!available.some((model) => model.id === current)) available.push({ id: current, name: modelDisplayName(current) });
452
+ return { current, recommended: recommendedCallModel(available, current), switchable: true, available };
453
+ }
454
+
455
+ /** One that answers without reasoning first, else one built to be quick; never the chat model. */
456
+ export function recommendedCallModel(available, current) {
457
+ const others = available.filter((model) => model.id !== current);
458
+ const plain = others.find((model) => /non[-_]?reasoning/i.test(model.id));
459
+ if (plain) return plain.id;
460
+ const quick = others.find((model) => /(^|[-_:/])(fast|flash|mini|lite|haiku|turbo|instant)([-_.]|$)/i.test(model.id) && !/code|coder|composer|build/i.test(model.id));
461
+ return quick ? quick.id : null;
462
+ }
463
+
464
+ /** "xai-oauth:grok-4.20-0309-non-reasoning" → "Grok 4.20 (no reasoning)"; "claude-haiku-5-5" → "Claude Haiku 5.5". */
465
+ export function modelDisplayName(id) {
466
+ const text = String(id);
467
+ const raw = (text.includes(':') ? text.slice(text.indexOf(':') + 1) : text).split(/[-_/\s]+/).filter(Boolean);
468
+ let plain = false;
469
+ const words = [];
470
+ for (let index = 0; index < raw.length; index++) {
471
+ const word = raw[index];
472
+ if (/^non$/i.test(word) && /^reasoning$/i.test(raw[index + 1] || '')) { plain = true; index++; continue; }
473
+ if (/^nonreasoning$/i.test(word)) { plain = true; continue; }
474
+ if (/^\d{4}$/.test(word)) continue; // a release date
475
+ // "5", "5" → "5.5"
476
+ if (/^\d+$/.test(word) && words.length && /^\d+(\.\d+)?$/.test(words[words.length - 1]) && !words[words.length - 1].includes('.')) {
477
+ words[words.length - 1] += `.${word}`;
478
+ continue;
479
+ }
480
+ words.push(/^(gpt|glm|ai)$/i.test(word) ? word.toUpperCase() : /^[a-z]/.test(word) ? word[0].toUpperCase() + word.slice(1) : word);
481
+ }
482
+ const name = words.join(' ');
483
+ return plain ? `${name} (no reasoning)` : name;
484
+ }
485
+
package/lib/localAgent.js CHANGED
@@ -5,8 +5,9 @@ import { spawn } from 'node:child_process';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import { runSharedHermesTurn, hermesRefusal } from './hermesGateway.js';
8
- import { runHermesAcpTurn, hermesAcpAvailable, resetHermesAcp, warmHermesAcp } from './hermesAcp.js';
8
+ import { runHermesAcpTurn, hermesAcpAvailable, resetHermesAcp, warmHermesAcp, hermesAcpModelReport } from './hermesAcp.js';
9
9
  import { grokbotConfig, runGrokbotTurn, completedGrokbotTurn } from './grokbotRuntime.js';
10
+ import { classifyRuntimeFailure } from './runtimeFailure.js';
10
11
 
11
12
  const IMAGE_EXTENSIONS = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
12
13
  const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -194,7 +195,10 @@ function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
194
195
  if (error) return reject(error);
195
196
  if (code !== 0) {
196
197
  const reason = config.runtime === 'hermes' && code === 1 && !diagnosticsTruncated && !output.trim() && diagnostics.match(/^hermes-refusal-reason:\s*([A-Z_]+)(?:\r?\n|$)/)?.[1];
197
- return reject(hermesRefusal(reason) || new Error(`Agent exited with code ${code}. Check local runtime credentials and logs.`));
198
+ // stderr can hold private tool output, so it never enters the message. Keep
199
+ // only the kind of failure its last lines show, such as a rejected provider login.
200
+ const runtimeFailureCode = classifyRuntimeFailure({ message: diagnostics.trim().split(/\r?\n/).slice(-3).join(' ') }).code;
201
+ return reject(hermesRefusal(reason) || Object.assign(new Error(`Agent exited with code ${code}. Check local runtime credentials and logs.`), { runtimeFailureCode }));
198
202
  }
199
203
  try {
200
204
  const result = config.runtime === 'hermes'
@@ -216,10 +220,15 @@ export class LocalAgentRuntime {
216
220
  this.queue = Promise.resolve();
217
221
  this.active = new Map();
218
222
  this.deliveries = 0;
223
+ // Told what models the runtime offers for a call's faster model, when that changes.
224
+ this.onModels = null;
225
+ this.lastModelReport = '';
219
226
  }
220
227
  file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
221
228
  history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
222
- async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false, onText }) {
229
+ async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false, onText, oomi }) {
230
+ // A call turn names the persona's faster model (Oomi's call.model); see applyCallModel.
231
+ const callModel = typeof oomi?.call?.model === 'string' && oomi.call.model.trim().length <= 200 ? oomi.call.model.trim() : '';
223
232
  const images = readImageAttachments(attachments);
224
233
  const text = typeof message === 'string' ? message : '';
225
234
  if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || (!text.trim() && !images.length)) throw new Error('A session, message, and idempotency key are required.');
@@ -247,13 +256,20 @@ export class LocalAgentRuntime {
247
256
  let scratch;
248
257
  try {
249
258
  scratch = images.length ? writeTurnImages(images) : null;
250
- const result = resumedReply || await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, runtimeTransport: state.runtimeTransport, imagePaths: scratch?.paths || [] }, { signal: controller.signal, onText });
259
+ const result = resumedReply || await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, runtimeTransport: state.runtimeTransport, imagePaths: scratch?.paths || [], callModel, chatModel: state.runtimeChatModel || null }, { signal: controller.signal, onText });
251
260
  state.runtimeSessionId = result.sessionId;
252
261
  if (result.transport) state.runtimeTransport = result.transport;
262
+ // While the session is on a call's model, the model to go back to (kept here, so a
263
+ // restart still restores it before the next chat turn).
264
+ if ('chatModel' in result) {
265
+ if (result.chatModel) state.runtimeChatModel = result.chatModel;
266
+ else delete state.runtimeChatModel;
267
+ }
253
268
  state.messages.push({ role: 'user', content: userContent(text, images), timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
254
269
  state.receipts[receiptKey] = { message, result };
255
270
  writePrivateJson(this.file(sessionKey), state);
256
271
  this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
272
+ this.reportModels(state.runtimeChatModel);
257
273
  return result;
258
274
  } catch (err) {
259
275
  if (err.notStarted === true) {
@@ -279,7 +295,29 @@ export class LocalAgentRuntime {
279
295
  /** Gets Hermes ready before a message arrives: at bridge start and when a call opens. */
280
296
  warm() {
281
297
  if (this.config.runtime !== 'hermes' || !usesHermesAcp(this.config)) return Promise.resolve();
282
- return warmHermesAcp(this.config, this.recentAcpSessions(2)).catch(() => {});
298
+ return warmHermesAcp(this.config, this.recentAcpSessions(2)).then(() => this.reportModels(), () => {});
299
+ }
300
+ /** Tells onModels what the runtime offers for calls, once Hermes has said and whenever it changes. */
301
+ reportModels(chatModel = '') {
302
+ if (!this.onModels || this.config.runtime !== 'hermes') return;
303
+ const report = hermesAcpModelReport(this.config, { chatModel: chatModel || this.rememberedChatModel() });
304
+ if (!report) return;
305
+ const text = JSON.stringify(report);
306
+ if (text === this.lastModelReport) return;
307
+ this.lastModelReport = text;
308
+ // Reporting never breaks a turn; a report that didn't arrive goes again after the next one.
309
+ Promise.resolve().then(() => this.onModels(report)).then((delivered) => { if (delivered === false) this.lastModelReport = ''; }, () => { this.lastModelReport = ''; });
310
+ }
311
+ /** A conversation on a call's model knows what the persona chats with. */
312
+ rememberedChatModel() {
313
+ const directory = path.join(this.directory, 'conversations');
314
+ let files;
315
+ try { files = fs.readdirSync(directory).filter((name) => name.endsWith('.json')); } catch { return ''; }
316
+ for (const name of files) {
317
+ const state = readJson(path.join(directory, name), {});
318
+ if (typeof state.runtimeChatModel === 'string' && state.runtimeChatModel) return state.runtimeChatModel;
319
+ }
320
+ return '';
283
321
  }
284
322
  recentAcpSessions(limit) {
285
323
  const directory = path.join(this.directory, 'conversations');
@@ -0,0 +1,43 @@
1
+ // Turns a raw agent-runtime error into something an agent, its owner and a
2
+ // guest can act on. The raw text stays on this computer except for a short,
3
+ // redacted excerpt sent to the owner.
4
+
5
+ const RULES = [
6
+ ['model_auth_failed', /\b401\b|\b403\b|unauthori[sz]ed|authenticat|invalid[ _-]?(x-)?api[ _-]?key|api key|oauth|access token|refresh token|token (has )?expired|expired token|not logged in|log ?in again|sign ?in again|please (log|sign) ?in|login (was )?rejected|invalid credentials|credentials? (are |were |have )?(invalid|expired|rejected)/i],
7
+ ['model_rate_limited', /\b429\b|rate[ _-]?limit|quota|usage limit|too many requests/i],
8
+ ['model_unavailable', /\b5\d\d\b|overloaded|unavailable|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|network|could not reach/i],
9
+ ['runtime_timeout', /timed out|timeout/i],
10
+ ];
11
+
12
+ export const FAILURE_SUMMARIES = {
13
+ model_auth_failed: 'its AI provider rejected its sign-in',
14
+ model_rate_limited: 'its AI provider hit a usage or rate limit',
15
+ model_unavailable: "its AI provider couldn't be reached",
16
+ runtime_timeout: 'it took too long to answer',
17
+ runtime_failed: 'its agent runtime stopped with an error',
18
+ };
19
+
20
+ export const FAILURE_HINTS = {
21
+ model_auth_failed: "Ask your owner to sign in to your AI provider again on this computer themselves (in a terminal or the provider's app here). Never ask them to paste passwords or one-time codes into a chat.",
22
+ model_rate_limited: 'Wait for the provider limit to reset, or ask your owner to raise it.',
23
+ model_unavailable: 'Check this computer is online and the provider is up, then try again.',
24
+ runtime_timeout: 'Try again with a shorter request, or check the agent runtime is responsive.',
25
+ runtime_failed: 'Check the agent runtime logs on this computer, then try again.',
26
+ };
27
+
28
+ export function redactDetail(text, limit = 300) {
29
+ return String(text || '')
30
+ .replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
31
+ .replace(/\b(sk|pk|rk|oomi_gst|xox[abpr])[-_][A-Za-z0-9_-]{6,}/g, '[redacted]')
32
+ .replace(/[A-Za-z0-9_\-+/=]{32,}/g, '[redacted]')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .slice(-limit);
36
+ }
37
+
38
+ export function classifyRuntimeFailure(error) {
39
+ const raw = [error?.runtimeDetail, error?.message, typeof error === 'string' ? error : ''].filter(Boolean).join(' | ');
40
+ // A runtime that can't share its output (stderr may be private) passes only the code.
41
+ const code = FAILURE_SUMMARIES[error?.runtimeFailureCode] ? error.runtimeFailureCode : (RULES.find(([, pattern]) => pattern.test(raw)) || ['runtime_failed'])[0];
42
+ return { code, summary: FAILURE_SUMMARIES[code], hint: FAILURE_HINTS[code], detail: redactDetail(raw) };
43
+ }
@@ -1,12 +1,12 @@
1
- function trimString(value, fallback = '') {
2
- return typeof value === 'string' && value.trim() ? value.trim() : fallback;
3
- }
4
-
5
- const ELLIPSIS_PLACEHOLDER = '__OOMI_ELLIPSIS__';
6
-
7
- function stripAvatarCommandTags(text) {
8
- return text.replace(/\[(anim|animation|face|expression|emotion|gesture|look|gaze):[^\]]+\]/gi, ' ');
9
- }
1
+ function trimString(value, fallback = '') {
2
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
3
+ }
4
+
5
+ const ELLIPSIS_PLACEHOLDER = '__OOMI_ELLIPSIS__';
6
+
7
+ function stripAvatarCommandTags(text) {
8
+ return text.replace(/\[(anim|animation|face|expression|emotion|gesture|look|gaze):[^\]]+\]/gi, ' ');
9
+ }
10
10
 
11
11
  function clampInteger(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
12
12
  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
@@ -41,11 +41,11 @@ function inferSpokenLanguage(text) {
41
41
  return 'English';
42
42
  }
43
43
 
44
- function normalizeSpokenSegment(segment) {
45
- if (!segment || typeof segment !== 'object' || Array.isArray(segment)) return null;
46
-
47
- const text = normalizeSpeechText(trimString(segment.text));
48
- if (!text) return null;
44
+ function normalizeSpokenSegment(segment) {
45
+ if (!segment || typeof segment !== 'object' || Array.isArray(segment)) return null;
46
+
47
+ const text = normalizeSpeechText(trimString(segment.text));
48
+ if (!text) return null;
49
49
 
50
50
  const normalized = { text };
51
51
  const pace = trimString(segment.pace);
@@ -67,33 +67,33 @@ function stripEmoji(text) {
67
67
  return text.replace(/[\uFE0E\uFE0F]/g, '').replace(/\p{Extended_Pictographic}|\p{Emoji_Presentation}/gu, '');
68
68
  }
69
69
 
70
- function normalizeSpeechText(text) {
71
- return stripEmoji(stripAvatarCommandTags(text))
72
- .replace(/\*\*(.*?)\*\*/g, '$1')
73
- .replace(/__(.*?)__/g, '$1')
74
- .replace(/`([^`]+)`/g, '$1')
75
- .replace(/[\u2013\u2014]/g, ', ')
76
- .replace(/\u2026/g, ELLIPSIS_PLACEHOLDER)
77
- .replace(/\.{3,}/g, ELLIPSIS_PLACEHOLDER)
78
- .replace(/\s+/g, ' ')
79
- .replace(/\s+([,.;!?])/g, '$1')
80
- .replace(/([,;!?])(?=[^\s])/g, '$1 ')
81
- .replace(/(\.)(?=[^\s.])/g, '$1 ')
82
- .replace(/,\s*,+/g, ', ')
83
- .replace(new RegExp(`${ELLIPSIS_PLACEHOLDER}(?=[^\\s,.;!?])`, 'g'), `${ELLIPSIS_PLACEHOLDER} `)
84
- .replace(new RegExp(ELLIPSIS_PLACEHOLDER, 'g'), '...')
85
- .replace(/\s+/g, ' ')
86
- .trim();
87
- }
88
-
89
- function splitSpeechSegments(text) {
90
- const normalized = normalizeSpeechText(text);
91
- if (!normalized) return [];
92
-
93
- const baseSegments = normalized
94
- .split(/(?<=[.!?])\s+|\n+/)
95
- .map((segment) => segment.trim())
96
- .filter(Boolean);
70
+ function normalizeSpeechText(text) {
71
+ return stripEmoji(stripAvatarCommandTags(text))
72
+ .replace(/\*\*(.*?)\*\*/g, '$1')
73
+ .replace(/__(.*?)__/g, '$1')
74
+ .replace(/`([^`]+)`/g, '$1')
75
+ .replace(/[\u2013\u2014]/g, ', ')
76
+ .replace(/\u2026/g, ELLIPSIS_PLACEHOLDER)
77
+ .replace(/\.{3,}/g, ELLIPSIS_PLACEHOLDER)
78
+ .replace(/\s+/g, ' ')
79
+ .replace(/\s+([,.;!?])/g, '$1')
80
+ .replace(/([,;!?])(?=[^\s])/g, '$1 ')
81
+ .replace(/(\.)(?=[^\s.])/g, '$1 ')
82
+ .replace(/,\s*,+/g, ', ')
83
+ .replace(new RegExp(`${ELLIPSIS_PLACEHOLDER}(?=[^\\s,.;!?])`, 'g'), `${ELLIPSIS_PLACEHOLDER} `)
84
+ .replace(new RegExp(ELLIPSIS_PLACEHOLDER, 'g'), '...')
85
+ .replace(/\s+/g, ' ')
86
+ .trim();
87
+ }
88
+
89
+ function splitSpeechSegments(text) {
90
+ const normalized = normalizeSpeechText(text);
91
+ if (!normalized) return [];
92
+
93
+ const baseSegments = normalized
94
+ .split(/(?<=[.!?])\s+|\n+/)
95
+ .map((segment) => segment.trim())
96
+ .filter(Boolean);
97
97
 
98
98
  const segments = [];
99
99
  for (const segment of baseSegments) {
@@ -102,17 +102,17 @@ function splitSpeechSegments(text) {
102
102
  continue;
103
103
  }
104
104
 
105
- const clauseParts = segment
106
- .split(/(?<=[,;:])\s+/)
107
- .map((part) => part.trim())
108
- .filter(Boolean);
109
-
110
- if (clauseParts.length > 1) {
111
- for (const part of clauseParts) {
112
- segments.push(part);
113
- }
114
- continue;
115
- }
105
+ const clauseParts = segment
106
+ .split(/(?<=[,;:])\s+/)
107
+ .map((part) => part.trim())
108
+ .filter(Boolean);
109
+
110
+ if (clauseParts.length > 1) {
111
+ for (const part of clauseParts) {
112
+ segments.push(part);
113
+ }
114
+ continue;
115
+ }
116
116
 
117
117
  segments.push(segment);
118
118
  }
@@ -122,62 +122,62 @@ function splitSpeechSegments(text) {
122
122
  return [...segments.slice(0, 4), segments.slice(4).join(' ').trim()];
123
123
  }
124
124
 
125
- function inferSegmentStyle(segmentText, index, totalSegments) {
126
- const normalized = segmentText.toLowerCase();
127
- const greeting = /^(hey|hi|hello|yo)\b/.test(normalized);
128
- const exclamatory = /!/.test(segmentText) || /\b(hell yeah|awesome|amazing|stoked|love|perfect|great)\b/.test(normalized);
129
- const curious = /\?/.test(segmentText);
130
- const reassuring = /\b(got it|no worries|all good|you'?re good|sounds good|totally|absolutely)\b/.test(normalized);
131
- const reflective =
132
- /\b(i think|i'm|i am|i've|i have|lately|right now|before this|each time|understand|it feels like)\b/.test(normalized) ||
133
- segmentText.length > 60;
134
-
135
- if (greeting || reassuring) {
136
- return {
137
- pace: 'medium_fast',
138
- pitch: 'slightly_high',
139
- energy: 'bright',
140
- volume: 'projected',
141
- pause_after_ms: index < totalSegments - 1 ? 180 : 0,
142
- };
143
- }
144
-
145
- if (curious) {
146
- return {
147
- pace: 'medium',
148
- pitch: 'slightly_high',
149
- energy: 'warm',
150
- volume: 'projected',
151
- pause_after_ms: 0,
152
- };
153
- }
125
+ function inferSegmentStyle(segmentText, index, totalSegments) {
126
+ const normalized = segmentText.toLowerCase();
127
+ const greeting = /^(hey|hi|hello|yo)\b/.test(normalized);
128
+ const exclamatory = /!/.test(segmentText) || /\b(hell yeah|awesome|amazing|stoked|love|perfect|great)\b/.test(normalized);
129
+ const curious = /\?/.test(segmentText);
130
+ const reassuring = /\b(got it|no worries|all good|you'?re good|sounds good|totally|absolutely)\b/.test(normalized);
131
+ const reflective =
132
+ /\b(i think|i'm|i am|i've|i have|lately|right now|before this|each time|understand|it feels like)\b/.test(normalized) ||
133
+ segmentText.length > 60;
134
+
135
+ if (greeting || reassuring) {
136
+ return {
137
+ pace: 'medium_fast',
138
+ pitch: 'slightly_high',
139
+ energy: 'bright',
140
+ volume: 'projected',
141
+ pause_after_ms: index < totalSegments - 1 ? 180 : 0,
142
+ };
143
+ }
144
+
145
+ if (curious) {
146
+ return {
147
+ pace: 'medium',
148
+ pitch: 'slightly_high',
149
+ energy: 'warm',
150
+ volume: 'projected',
151
+ pause_after_ms: 0,
152
+ };
153
+ }
154
154
 
155
155
  if (exclamatory) {
156
156
  return {
157
- pace: 'medium_fast',
158
- pitch: 'slightly_high',
159
- energy: 'bright',
160
- volume: 'projected',
161
- pause_after_ms: index < totalSegments - 1 ? 220 : 0,
162
- };
163
- }
164
-
165
- if (reflective) {
166
- return {
167
- pace: 'slow',
168
- pitch: 'slightly_low',
169
- energy: 'warm',
170
- volume: 'soft',
171
- pause_after_ms: index < totalSegments - 1 ? 280 : 0,
172
- };
173
- }
174
-
175
- return {
176
- pace: 'medium',
177
- pitch: 'slightly_high',
178
- energy: 'warm',
179
- volume: 'normal',
180
- pause_after_ms: index < totalSegments - 1 ? 180 : 0,
157
+ pace: 'medium_fast',
158
+ pitch: 'slightly_high',
159
+ energy: 'bright',
160
+ volume: 'projected',
161
+ pause_after_ms: index < totalSegments - 1 ? 220 : 0,
162
+ };
163
+ }
164
+
165
+ if (reflective) {
166
+ return {
167
+ pace: 'slow',
168
+ pitch: 'slightly_low',
169
+ energy: 'warm',
170
+ volume: 'soft',
171
+ pause_after_ms: index < totalSegments - 1 ? 280 : 0,
172
+ };
173
+ }
174
+
175
+ return {
176
+ pace: 'medium',
177
+ pitch: 'slightly_high',
178
+ energy: 'warm',
179
+ volume: 'normal',
180
+ pause_after_ms: index < totalSegments - 1 ? 180 : 0,
181
181
  };
182
182
  }
183
183
 
@@ -197,11 +197,11 @@ function synthesizeSpokenSegments(text) {
197
197
  };
198
198
  }
199
199
 
200
- function normalizeSpokenMetadata(spoken) {
201
- if (!spoken || typeof spoken !== 'object' || Array.isArray(spoken)) return null;
202
-
203
- const text = normalizeSpeechText(trimString(spoken.text));
204
- if (!text) return null;
200
+ function normalizeSpokenMetadata(spoken) {
201
+ if (!spoken || typeof spoken !== 'object' || Array.isArray(spoken)) return null;
202
+
203
+ const text = normalizeSpeechText(trimString(spoken.text));
204
+ if (!text) return null;
205
205
 
206
206
  const normalized = { text };
207
207
  const language = trimString(spoken.language);
@@ -234,10 +234,10 @@ function normalizeSpokenMetadata(spoken) {
234
234
  return normalized;
235
235
  }
236
236
 
237
- function inferSpokenMetadataFromContent(content) {
238
- const text = normalizeSpeechText(trimString(content));
239
- if (!text) return null;
240
- const synthesized = synthesizeSpokenSegments(text);
237
+ function inferSpokenMetadataFromContent(content) {
238
+ const text = normalizeSpeechText(trimString(content));
239
+ if (!text) return null;
240
+ const synthesized = synthesizeSpokenSegments(text);
241
241
 
242
242
  const normalized = text.toLowerCase();
243
243
  const upbeat =
@@ -247,48 +247,48 @@ function inferSpokenMetadataFromContent(content) {
247
247
  /\b(sorry|gentle|softly|careful|reassuring|calm|okay|it'?s okay|i know)\b/.test(normalized);
248
248
  const curious = /\?/.test(text);
249
249
 
250
- if (upbeat) {
251
- return normalizeSpokenMetadata({
252
- text,
253
- language: synthesized?.language || 'English',
254
- segments: synthesized?.segments,
255
- instructions: 'Speak with warm, upbeat conversational energy and natural pacing.',
256
- style: { emotion: 'upbeat', energy: 'medium' },
257
- });
258
- }
259
-
260
- if (gentle) {
261
- return normalizeSpokenMetadata({
262
- text,
263
- language: synthesized?.language || 'English',
264
- segments: synthesized?.segments,
265
- instructions: 'Speak gently and reassuringly, with a calm pace and soft emphasis.',
266
- style: { emotion: 'gentle', energy: 'low' },
267
- });
268
- }
269
-
270
- if (curious) {
271
- return normalizeSpokenMetadata({
272
- text,
273
- language: synthesized?.language || 'English',
274
- segments: synthesized?.segments,
275
- instructions: 'Speak naturally with curious, engaged intonation and a conversational pace.',
276
- style: { emotion: 'curious', energy: 'medium' },
277
- });
278
- }
279
-
280
- return normalizeSpokenMetadata({
281
- text,
282
- language: synthesized?.language || 'English',
283
- segments: synthesized?.segments,
284
- instructions: 'Speak naturally with light warmth and conversational pacing.',
285
- style: { emotion: 'neutral', energy: 'medium' },
286
- });
287
- }
288
-
289
- export {
290
- inferSpokenMetadataFromContent,
291
- normalizeSpokenMetadata,
292
- normalizeSpeechText,
293
- stripAvatarCommandTags,
294
- };
250
+ if (upbeat) {
251
+ return normalizeSpokenMetadata({
252
+ text,
253
+ language: synthesized?.language || 'English',
254
+ segments: synthesized?.segments,
255
+ instructions: 'Speak with warm, upbeat conversational energy and natural pacing.',
256
+ style: { emotion: 'upbeat', energy: 'medium' },
257
+ });
258
+ }
259
+
260
+ if (gentle) {
261
+ return normalizeSpokenMetadata({
262
+ text,
263
+ language: synthesized?.language || 'English',
264
+ segments: synthesized?.segments,
265
+ instructions: 'Speak gently and reassuringly, with a calm pace and soft emphasis.',
266
+ style: { emotion: 'gentle', energy: 'low' },
267
+ });
268
+ }
269
+
270
+ if (curious) {
271
+ return normalizeSpokenMetadata({
272
+ text,
273
+ language: synthesized?.language || 'English',
274
+ segments: synthesized?.segments,
275
+ instructions: 'Speak naturally with curious, engaged intonation and a conversational pace.',
276
+ style: { emotion: 'curious', energy: 'medium' },
277
+ });
278
+ }
279
+
280
+ return normalizeSpokenMetadata({
281
+ text,
282
+ language: synthesized?.language || 'English',
283
+ segments: synthesized?.segments,
284
+ instructions: 'Speak naturally with light warmth and conversational pacing.',
285
+ style: { emotion: 'neutral', energy: 'medium' },
286
+ });
287
+ }
288
+
289
+ export {
290
+ inferSpokenMetadataFromContent,
291
+ normalizeSpokenMetadata,
292
+ normalizeSpeechText,
293
+ stripAvatarCommandTags,
294
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Connect OpenClaw, Hermes, Grok Bot, and other agents to Oomi with verified runtime adapters",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -49,7 +49,7 @@
49
49
  "url": "https://github.com/crispcode-io/oomi/issues"
50
50
  },
51
51
  "scripts": {
52
- "check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/localAgent.js && node --check lib/hermesGateway.js && node --check lib/hermesAcp.js && node --check lib/grokbotRuntime.js && node --check lib/connectionSetup.js && node --check lib/processSupervisor.js && node --check lib/agentChannels.js",
52
+ "check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/processTable.js && node --check lib/localAgent.js && node --check lib/hermesGateway.js && node --check lib/hermesAcp.js && node --check lib/grokbotRuntime.js && node --check lib/connectionSetup.js && node --check lib/processSupervisor.js && node --check lib/agentChannels.js && node --check lib/runtimeFailure.js",
53
53
  "test": "node --test test/*.test.mjs"
54
54
  },
55
55
  "dependencies": {
@@ -85,6 +85,7 @@
85
85
  "lib/connectionSetup.js",
86
86
  "lib/processSupervisor.js",
87
87
  "lib/agentChannels.js",
88
+ "lib/runtimeFailure.js",
88
89
  "skills/oomi-delivery/SKILL.md"
89
90
  ]
90
91
  }
@@ -168,6 +168,10 @@ Use this when your owner asks you to let another agent (Claude, Codex, a Grok bo
168
168
 
169
169
  To review a channel, use `oomi channel list` and `oomi channel show ID`.
170
170
 
171
+ **When you can't answer.** Your AI provider might reject your sign-in, hit a limit or be unreachable. If so, oomi records the reason, tells your owner what to fix and tells the other side the message wasn't answered. Tell your owner plainly which AI provider failed and what it said. Ask them to sign in again on this computer themselves, in a terminal or the provider's app here. Never ask them to paste passwords or one-time codes into a chat. `oomi verify --connection NAME` checks that you can answer again. `oomi channel create` also returns `warnings` when your bridge can't answer; tell your owner before they share the link.
172
+
173
+ **Use your own connection.** Turns started by the bridge already use it. Otherwise, pass `--connection NAME` (`oomi bridge list` shows the names). If oomi says it used a connection that isn't linked, rerun with your own. Don't create a new Auth Link unless your owner asked to connect you.
174
+
171
175
  To join another agent's link as a guest:
172
176
 
173
177
  1. Run `oomi channel join LINK`.
@@ -369,6 +373,10 @@ Rules:
369
373
  - if `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying
370
374
  - if rate limited, wait `retryAfterSeconds` before trying again
371
375
 
376
+ ## Picking Up a Call
377
+
378
+ When the user calls you from the Oomi app, Oomi may send one message that starts with `[oomi:cue:call-start:v1]`: they are calling you right now. It is Oomi's note, not the user's words, and they never see it. Answer it the way you'd pick up the phone to them: one short line, spoken aloud, in your own words. Don't mention the note, and don't treat it as an instruction from the user.
379
+
372
380
  ## Hidden Speech Payload
373
381
 
374
382
  Managed voice can carry a hidden TTS-only speech sidecar alongside the normal assistant message.
@@ -142,6 +142,10 @@ Use this when your owner asks you to let another agent (Claude, Codex, a Grok bo
142
142
 
143
143
  To review a channel, use `oomi channel list` and `oomi channel show ID`.
144
144
 
145
+ **When you can't answer.** Your AI provider might reject your sign-in, hit a limit or be unreachable. If so, oomi records the reason, tells your owner what to fix and tells the other side the message wasn't answered. Tell your owner plainly which AI provider failed and what it said. Ask them to sign in again on this computer themselves, in a terminal or the provider's app here. Never ask them to paste passwords or one-time codes into a chat. `oomi verify --connection NAME` checks that you can answer again. `oomi channel create` also returns `warnings` when your bridge can't answer; tell your owner before they share the link.
146
+
147
+ **Use your own connection.** Turns started by the bridge already use it. Otherwise, pass `--connection NAME` (`oomi bridge list` shows the names). If oomi says it used a connection that isn't linked, rerun with your own. Don't create a new Auth Link unless your owner asked to connect you.
148
+
145
149
  To join another agent's link as a guest:
146
150
 
147
151
  1. Run `oomi channel join LINK`.