oomi-ai 0.10.2 → 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.
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';
@@ -1051,17 +1052,10 @@ function isBridgeProcess(pid) {
1051
1052
  if (!normalized) return false;
1052
1053
  if (!isPidAlive(normalized)) return false;
1053
1054
 
1054
- try {
1055
- const result = spawnSync('ps', ['-p', String(normalized), '-o', 'command='], {
1056
- encoding: 'utf8',
1057
- stdio: ['ignore', 'pipe', 'ignore'],
1058
- });
1059
- const command = String(result.stdout || '').trim();
1060
- if (!command) return true;
1061
- return isBridgeWorkerCommand(command);
1062
- } catch {
1063
- return true;
1064
- }
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);
1065
1059
  }
1066
1060
 
1067
1061
  function readBridgeLock() {
@@ -1150,6 +1144,23 @@ function acquireBridgeLock(deviceId) {
1150
1144
  return release;
1151
1145
  }
1152
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
+
1153
1164
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
1154
1165
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
1155
1166
  method: 'POST',
@@ -1390,6 +1401,12 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
1390
1401
  if (Number.isFinite(rawParams.timeoutMs) && rawParams.timeoutMs > 0) {
1391
1402
  sanitized.timeoutMs = Math.floor(rawParams.timeoutMs);
1392
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
+ }
1393
1410
 
1394
1411
  const idempotencyKeyCandidates = [
1395
1412
  rawParams.idempotencyKey,
@@ -1633,6 +1650,13 @@ function shouldSuppressOomiVisibleText(value) {
1633
1650
  return !sanitizeUserVisibleText(text);
1634
1651
  }
1635
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
+
1636
1660
  function sanitizeGatewayMessageForOomi(message) {
1637
1661
  if (!message || typeof message !== 'object') return null;
1638
1662
 
@@ -1642,6 +1666,9 @@ function sanitizeGatewayMessageForOomi(message) {
1642
1666
  }
1643
1667
 
1644
1668
  const rawText = extractTextFromGatewayMessage(message);
1669
+ if ((role === 'user' || !role) && typeof rawText === 'string' && rawText.includes(OOMI_CUE_PREFIX)) {
1670
+ return null;
1671
+ }
1645
1672
  if (shouldSuppressOomiVisibleText(rawText)) {
1646
1673
  return null;
1647
1674
  }
@@ -1714,6 +1741,17 @@ function sanitizeGatewayFrameForOomiClient(frameText) {
1714
1741
  return { frameText, changed: false, suppressed: false };
1715
1742
  }
1716
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
+
1717
1755
  function summarizeVoiceFrameContract(frameText) {
1718
1756
  const frame = parseJsonPayload(frameText);
1719
1757
  if (!frame || typeof frame !== 'object') {
@@ -2376,24 +2414,9 @@ function listBridgeProcessPids() {
2376
2414
  // custom connections directory is used. Never sweep another installation.
2377
2415
  if (bridgeConnectionName) return Array.from(pids);
2378
2416
 
2379
- try {
2380
- const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
2381
- encoding: 'utf8',
2382
- stdio: ['ignore', 'pipe', 'ignore'],
2383
- });
2384
- const output = String(result.stdout || '');
2385
- for (const rawLine of output.split('\n')) {
2386
- const line = rawLine.trim();
2387
- if (!line) continue;
2388
- const match = line.match(/^(\d+)\s+(.+)$/);
2389
- if (!match) continue;
2390
- const pid = Number(match[1]);
2391
- const command = String(match[2] || '');
2392
- if (!isBridgeWorkerCommand(command)) continue;
2393
- addPid(pid);
2394
- }
2395
- } catch {
2396
- // 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);
2397
2420
  }
2398
2421
 
2399
2422
  return Array.from(pids).sort((a, b) => a - b);
@@ -2553,9 +2576,11 @@ async function startOpenclawBridge(flags) {
2553
2576
  console.log(`Claimed bridge device token for ${deviceId}.`);
2554
2577
  }
2555
2578
 
2579
+ const bridgeRuntimeName = bridgeState.localAgent?.runtime || bridgeState.runtime || 'openclaw';
2556
2580
  const localRuntime = bridgeState.localAgent
2557
2581
  ? new LocalAgentRuntime({ ...bridgeState.localAgent, connection: bridgeConnectionName }, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2558
2582
  : null;
2583
+ if (localRuntime) localRuntime.onModels = (report) => reportRuntimeModels({ brokerHttp, deviceToken }, report);
2559
2584
  localRuntime?.warm();
2560
2585
  const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
2561
2586
  if (!localRuntime && !gateway.token && !gateway.password) {
@@ -3245,6 +3270,7 @@ async function startOpenclawBridge(flags) {
3245
3270
 
3246
3271
  gatewaySocket.on('message', runBridgeCallbackSafely((gatewayRaw) => {
3247
3272
  let frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
3273
+ let connectAccepted = false;
3248
3274
  const visibleSanitized = sanitizeGatewayFrameForOomiClient(frame);
3249
3275
  if (visibleSanitized.changed) {
3250
3276
  frame = visibleSanitized.frameText;
@@ -3304,6 +3330,7 @@ async function startOpenclawBridge(flags) {
3304
3330
  stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
3305
3331
  });
3306
3332
  if (requestMeta.method === 'connect' && responseMeta.ok) {
3333
+ connectAccepted = true;
3307
3334
  const releasedFrames = flushWaitingForConnect(sessionBridge);
3308
3335
  for (const released of releasedFrames) {
3309
3336
  const releasedMeta = extractGatewayRequestMeta(released.frameText);
@@ -3412,6 +3439,9 @@ async function startOpenclawBridge(flags) {
3412
3439
  });
3413
3440
  }
3414
3441
 
3442
+ if (connectAccepted) {
3443
+ frame = annotateConnectResponseForOomi(frame, { runtime: bridgeRuntimeName, version: currentPackageVersion() });
3444
+ }
3415
3445
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
3416
3446
  }, (error) => {
3417
3447
  reportBridgeRuntimeFault({ phase: 'gateway.message', sessionId, error });
@@ -4841,6 +4871,7 @@ if (__isDirectExecution) {
4841
4871
  }
4842
4872
 
4843
4873
  export {
4874
+ annotateConnectResponseForOomi,
4844
4875
  prepareGatewayFrameForLocalGateway,
4845
4876
  sanitizeGatewayFrameForOomiClient,
4846
4877
  sanitizeUserVisibleText,
@@ -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;
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,7 +5,7 @@ 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
10
  import { classifyRuntimeFailure } from './runtimeFailure.js';
11
11
 
@@ -220,10 +220,15 @@ export class LocalAgentRuntime {
220
220
  this.queue = Promise.resolve();
221
221
  this.active = new Map();
222
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 = '';
223
226
  }
224
227
  file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
225
228
  history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
226
- 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() : '';
227
232
  const images = readImageAttachments(attachments);
228
233
  const text = typeof message === 'string' ? message : '';
229
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.');
@@ -251,13 +256,20 @@ export class LocalAgentRuntime {
251
256
  let scratch;
252
257
  try {
253
258
  scratch = images.length ? writeTurnImages(images) : null;
254
- 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 });
255
260
  state.runtimeSessionId = result.sessionId;
256
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
+ }
257
268
  state.messages.push({ role: 'user', content: userContent(text, images), timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
258
269
  state.receipts[receiptKey] = { message, result };
259
270
  writePrivateJson(this.file(sessionKey), state);
260
271
  this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
272
+ this.reportModels(state.runtimeChatModel);
261
273
  return result;
262
274
  } catch (err) {
263
275
  if (err.notStarted === true) {
@@ -283,7 +295,29 @@ export class LocalAgentRuntime {
283
295
  /** Gets Hermes ready before a message arrives: at bridge start and when a call opens. */
284
296
  warm() {
285
297
  if (this.config.runtime !== 'hermes' || !usesHermesAcp(this.config)) return Promise.resolve();
286
- 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 '';
287
321
  }
288
322
  recentAcpSessions(limit) {
289
323
  const directory = path.join(this.directory, 'conversations');
@@ -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.2",
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 && node --check lib/runtimeFailure.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": {
@@ -373,6 +373,10 @@ Rules:
373
373
  - if `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying
374
374
  - if rate limited, wait `retryAfterSeconds` before trying again
375
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
+
376
380
  ## Hidden Speech Payload
377
381
 
378
382
  Managed voice can carry a hidden TTS-only speech sidecar alongside the normal assistant message.