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.
- package/agent_instructions.md +4 -0
- package/bin/oomi-ai.js +78 -34
- package/lib/agentChannels.js +52 -3
- package/lib/bridgeMaintenance.js +5 -4
- package/lib/gatewayProbe.js +6 -1
- package/lib/hermesAcp.js +115 -2
- package/lib/localAgent.js +43 -5
- package/lib/runtimeFailure.js +43 -0
- package/lib/spokenMetadata.js +159 -159
- package/package.json +3 -2
- package/skills/oomi/SKILL.md +8 -0
- package/skills/oomi/agent_instructions.md +4 -0
package/agent_instructions.md
CHANGED
|
@@ -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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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:
|
|
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
|
-
|
|
4523
|
-
|
|
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
|
-
|
|
4680
|
-
|
|
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,
|
package/lib/agentChannels.js
CHANGED
|
@@ -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
|
-
|
|
248
|
-
|
|
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() : '';
|
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/gatewayProbe.js
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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)).
|
|
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
|
+
}
|
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",
|
|
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
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -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`.
|