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 +60 -29
- package/lib/bridgeMaintenance.js +5 -4
- package/lib/hermesAcp.js +115 -2
- package/lib/localAgent.js +38 -4
- package/lib/spokenMetadata.js +159 -159
- package/package.json +2 -2
- package/skills/oomi/SKILL.md +4 -0
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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,
|
package/lib/bridgeMaintenance.js
CHANGED
|
@@ -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
|
|
25
|
-
if (
|
|
26
|
-
const parents = new Map(
|
|
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)).
|
|
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');
|
package/lib/spokenMetadata.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -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.
|