oomi-ai 0.12.1 → 0.13.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 +76 -75
- package/lib/agentChannels.js +42 -3
- package/lib/agentIdentity.js +3 -1
- package/lib/chatModels.js +93 -0
- package/lib/gatewayProbe.js +29 -5
- package/lib/gatewayRuns.js +29 -0
- package/lib/hermesAcp.js +53 -76
- package/lib/localAgent.js +86 -39
- package/lib/openclawModels.js +71 -0
- package/lib/runtimeFailure.js +2 -0
- package/lib/updateWatcher.js +56 -0
- package/package.json +6 -2
- package/skills/oomi/SKILL.md +3 -1
package/bin/oomi-ai.js
CHANGED
|
@@ -12,6 +12,10 @@ 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 { createGatewayRuns } from '../lib/gatewayRuns.js';
|
|
16
|
+
import { CHAT_MODELS_CAPABILITY } from '../lib/chatModels.js';
|
|
17
|
+
import { OpenclawModelRequests } from '../lib/openclawModels.js';
|
|
18
|
+
import { startUpdateWatcher } from '../lib/updateWatcher.js';
|
|
15
19
|
import { processCommand, readProcessTable } from '../lib/processTable.js';
|
|
16
20
|
import { bridgeServiceConfig, createBridgeService, renderLaunchAgent } from '../lib/bridgeService.js';
|
|
17
21
|
import { LOCAL_PROTOCOL_MIN, LOCAL_PROTOCOL_MAX, probeGateway } from '../lib/gatewayProbe.js';
|
|
@@ -1146,28 +1150,13 @@ function acquireBridgeLock(deviceId) {
|
|
|
1146
1150
|
return release;
|
|
1147
1151
|
}
|
|
1148
1152
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
*/
|
|
1153
|
-
async function reportRuntimeModels({ brokerHttp, deviceToken }, report, fetchImpl = fetch) {
|
|
1154
|
-
if (!brokerHttp || !deviceToken) return false;
|
|
1155
|
-
try {
|
|
1156
|
-
const response = await fetchImpl(`${brokerHttp.replace(/\/$/, '')}/v1/agent_runtime/models`, {
|
|
1157
|
-
method: 'PUT',
|
|
1158
|
-
headers: { Authorization: `Bearer ${deviceToken}`, 'Content-Type': 'application/json' },
|
|
1159
|
-
body: JSON.stringify(report),
|
|
1160
|
-
signal: AbortSignal.timeout(10000),
|
|
1161
|
-
});
|
|
1162
|
-
return response.ok;
|
|
1163
|
-
} catch { return false; }
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
|
|
1153
|
+
// A computer already linked to an account re-pairs only by showing the token it was given (even
|
|
1154
|
+
// an expired one): the backend won't hand a linked device's token to a pair code alone.
|
|
1155
|
+
async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId, proofToken = '' }) {
|
|
1167
1156
|
const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
|
|
1168
1157
|
method: 'POST',
|
|
1169
1158
|
signal: AbortSignal.timeout(15000),
|
|
1170
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1159
|
+
headers: { 'Content-Type': 'application/json', ...(proofToken ? { Authorization: `Bearer ${proofToken}` } : {}) },
|
|
1171
1160
|
body: JSON.stringify({ pairCode, deviceId }),
|
|
1172
1161
|
});
|
|
1173
1162
|
const payload = await response.json().catch(() => ({}));
|
|
@@ -1367,6 +1356,12 @@ function loadGatewayDeviceIdentity() {
|
|
|
1367
1356
|
return null;
|
|
1368
1357
|
}
|
|
1369
1358
|
|
|
1359
|
+
// What an oomi app may ask of the person's agent gateway through this bridge. The bridge signs in
|
|
1360
|
+
// to OpenClaw with the owner's own token and device identity, so anything else (config, exec
|
|
1361
|
+
// approvals, cron, other sessions' history) never passes, whoever holds an app session.
|
|
1362
|
+
// `trusted` frames are the bridge's own (the /model translator's models.list / sessions.patch).
|
|
1363
|
+
const CLIENT_GATEWAY_METHODS = new Set(['chat.send', 'chat.history', 'chat.abort', 'oomi.models.list', 'oomi.models.set']);
|
|
1364
|
+
|
|
1370
1365
|
function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}) {
|
|
1371
1366
|
const connectNonce = typeof options.connectNonce === 'string' ? options.connectNonce.trim() : '';
|
|
1372
1367
|
const deviceIdentity = options.deviceIdentity || null;
|
|
@@ -1382,6 +1377,9 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
1382
1377
|
}
|
|
1383
1378
|
|
|
1384
1379
|
if (method !== 'connect') {
|
|
1380
|
+
if (options.trusted !== true && !CLIENT_GATEWAY_METHODS.has(method)) {
|
|
1381
|
+
return { frameText: '', waitForChallenge: false, refusedMethod: method };
|
|
1382
|
+
}
|
|
1385
1383
|
const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
|
|
1386
1384
|
if (method === 'chat.send') {
|
|
1387
1385
|
const sanitized = {};
|
|
@@ -1403,18 +1401,10 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
1403
1401
|
if (Number.isFinite(rawParams.timeoutMs) && rawParams.timeoutMs > 0) {
|
|
1404
1402
|
sanitized.timeoutMs = Math.floor(rawParams.timeoutMs);
|
|
1405
1403
|
}
|
|
1406
|
-
// A call turn's faster model, for the runtimes Oomi drives itself (OpenClaw's gateway
|
|
1407
|
-
// would reject the field).
|
|
1408
|
-
const callModel = rawParams.oomi?.call?.model;
|
|
1409
|
-
if (gatewayAuth?.gatewayUrl === 'local-agent:' && typeof callModel === 'string' && callModel.trim() && callModel.trim().length <= 200) {
|
|
1410
|
-
sanitized.oomi = { call: { model: callModel.trim() } };
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
1404
|
const practiceBrief = rawParams.oomi?.call?.practiceBrief;
|
|
1414
1405
|
const context = practiceContext(practiceBrief);
|
|
1415
1406
|
if (context && gatewayAuth?.gatewayUrl === 'local-agent:') {
|
|
1416
|
-
sanitized.oomi
|
|
1417
|
-
sanitized.oomi.call.practiceBrief = practiceBrief;
|
|
1407
|
+
sanitized.oomi = { call: { practiceBrief } };
|
|
1418
1408
|
} else if (context && typeof sanitized.message === 'string') {
|
|
1419
1409
|
// OpenClaw rejects custom metadata; carry hidden context on every turn,
|
|
1420
1410
|
// including an ordinary transcript retry after a rejected greeting.
|
|
@@ -1497,42 +1487,11 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
1497
1487
|
}
|
|
1498
1488
|
params.client = client;
|
|
1499
1489
|
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
const requiredScopes = ['operator.read', 'operator.write'];
|
|
1506
|
-
for (const scope of requiredScopes) {
|
|
1507
|
-
if (!existingScopes.includes(scope)) {
|
|
1508
|
-
existingScopes.push(scope);
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
params.scopes = existingScopes;
|
|
1512
|
-
|
|
1513
|
-
params.caps = Array.isArray(rawParams.caps)
|
|
1514
|
-
? rawParams.caps.filter((value) => typeof value === 'string' && value.trim())
|
|
1515
|
-
: [];
|
|
1516
|
-
|
|
1517
|
-
params.commands = Array.isArray(rawParams.commands)
|
|
1518
|
-
? rawParams.commands.filter((value) => typeof value === 'string' && value.trim())
|
|
1519
|
-
: [];
|
|
1520
|
-
|
|
1521
|
-
if (rawParams.permissions && typeof rawParams.permissions === 'object') {
|
|
1522
|
-
const permissions = {};
|
|
1523
|
-
for (const [key, value] of Object.entries(rawParams.permissions)) {
|
|
1524
|
-
const normalizedKey = typeof key === 'string' ? key.trim() : '';
|
|
1525
|
-
if (!normalizedKey || typeof value !== 'boolean') continue;
|
|
1526
|
-
permissions[normalizedKey] = value;
|
|
1527
|
-
}
|
|
1528
|
-
if (Object.keys(permissions).length > 0) {
|
|
1529
|
-
params.permissions = permissions;
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
if (typeof rawParams.pathEnv === 'string') {
|
|
1534
|
-
params.pathEnv = rawParams.pathEnv;
|
|
1535
|
-
}
|
|
1490
|
+
// The bridge decides what the app's connection may do, never the app: chat as an operator.
|
|
1491
|
+
params.role = 'operator';
|
|
1492
|
+
params.scopes = ['operator.read', 'operator.write'];
|
|
1493
|
+
params.caps = [];
|
|
1494
|
+
params.commands = [];
|
|
1536
1495
|
|
|
1537
1496
|
const auth = {};
|
|
1538
1497
|
if (gatewayAuth.token) {
|
|
@@ -1570,7 +1529,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
1570
1529
|
clientId: client.id,
|
|
1571
1530
|
clientMode: client.mode,
|
|
1572
1531
|
role: params.role,
|
|
1573
|
-
scopes:
|
|
1532
|
+
scopes: params.scopes,
|
|
1574
1533
|
signedAtMs,
|
|
1575
1534
|
token: tokenForSignature,
|
|
1576
1535
|
nonce: nonceForSignature,
|
|
@@ -1759,14 +1718,21 @@ function sanitizeGatewayFrameForOomiClient(frameText) {
|
|
|
1759
1718
|
}
|
|
1760
1719
|
|
|
1761
1720
|
// The runtime accepted oomi's connect: say what this bridge can do. Grok Bot gets no greeting (its
|
|
1762
|
-
// inbox is slow, and Grok's own app would show the cue).
|
|
1763
|
-
|
|
1721
|
+
// inbox is slow, and Grok's own app would show the cue). `extra` is what the local runtime adds
|
|
1722
|
+
// (Hermes over ACP: chat.models.v1, `/model` in Talk).
|
|
1723
|
+
function annotateConnectResponseForOomi(frameText, { runtime = 'openclaw', version = '', extra = [] } = {}) {
|
|
1764
1724
|
const frame = parseJsonPayload(frameText);
|
|
1765
1725
|
if (!frame || typeof frame !== 'object' || frame.type !== 'res' || !frame.ok) return frameText;
|
|
1766
1726
|
const payload = frame.payload && typeof frame.payload === 'object' && !Array.isArray(frame.payload) ? frame.payload : {};
|
|
1767
1727
|
const existing = payload.oomi && typeof payload.oomi === 'object' && !Array.isArray(payload.oomi) ? payload.oomi : {};
|
|
1768
|
-
const capabilities = runtime === 'grokbot' ? [] :
|
|
1769
|
-
|
|
1728
|
+
const capabilities = [...(runtime === 'grokbot' ? [] : OOMI_BRIDGE_CAPABILITIES), ...extra];
|
|
1729
|
+
// The gateway's connect answer can carry credentials for this connection (an issued device
|
|
1730
|
+
// token); they're the bridge's, and never leave the computer.
|
|
1731
|
+
const { auth, ...rest } = payload;
|
|
1732
|
+
const safeAuth = auth && typeof auth === 'object' && !Array.isArray(auth)
|
|
1733
|
+
? Object.fromEntries(Object.entries(auth).filter(([key]) => !/token|secret|password|key/i.test(key)))
|
|
1734
|
+
: undefined;
|
|
1735
|
+
return JSON.stringify({ ...frame, payload: { ...rest, ...(safeAuth ? { auth: safeAuth } : {}), oomi: { ...existing, bridgeVersion: version, capabilities } } });
|
|
1770
1736
|
}
|
|
1771
1737
|
|
|
1772
1738
|
function summarizeVoiceFrameContract(frameText) {
|
|
@@ -2617,7 +2583,6 @@ async function startOpenclawBridge(flags) {
|
|
|
2617
2583
|
const localRuntime = bridgeState.localAgent
|
|
2618
2584
|
? new LocalAgentRuntime({ ...bridgeState.localAgent, connection: bridgeConnectionName }, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
|
|
2619
2585
|
: null;
|
|
2620
|
-
if (localRuntime) localRuntime.onModels = (report) => reportRuntimeModels({ brokerHttp, deviceToken }, report);
|
|
2621
2586
|
localRuntime?.warm();
|
|
2622
2587
|
const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
|
|
2623
2588
|
if (!localRuntime && !gateway.token && !gateway.password) {
|
|
@@ -2718,6 +2683,16 @@ async function startOpenclawBridge(flags) {
|
|
|
2718
2683
|
: `Agent channel delivery: ${error.message}` }),
|
|
2719
2684
|
onDelivered: () => updateBridgeStatus({ agentChannelError: '' }),
|
|
2720
2685
|
});
|
|
2686
|
+
// Asks the agent to keep oomi-ai current, once per new version (lib/updateWatcher.js).
|
|
2687
|
+
const stopUpdateWatcher = startUpdateWatcher({
|
|
2688
|
+
current: currentPackageVersion(),
|
|
2689
|
+
fetchLatest: () => fetchLatestPublishedVersion('oomi-ai'),
|
|
2690
|
+
deliver: deliverAgentEvent,
|
|
2691
|
+
sessionKey: `${(bridgeState.sessionKey || 'agent:main:').split(':').slice(0, 2).join(':')}:oomi:maintenance`,
|
|
2692
|
+
receiptPath: path.join(path.dirname(resolveBridgeStatePath()), 'oomi-update-receipt.json'),
|
|
2693
|
+
connection: bridgeConnectionName,
|
|
2694
|
+
onStatus: (patch) => updateBridgeStatus(patch),
|
|
2695
|
+
});
|
|
2721
2696
|
console.log(`Local gateway: ${gateway.gatewayUrl}`);
|
|
2722
2697
|
console.log(`Broker WS: ${brokerWs}`);
|
|
2723
2698
|
bridgeDebugLog(
|
|
@@ -2725,7 +2700,7 @@ async function startOpenclawBridge(flags) {
|
|
|
2725
2700
|
);
|
|
2726
2701
|
|
|
2727
2702
|
const activeGatewaySockets = new Map();
|
|
2728
|
-
const activeGatewayRuns =
|
|
2703
|
+
const activeGatewayRuns = createGatewayRuns();
|
|
2729
2704
|
let currentBrokerSocket;
|
|
2730
2705
|
const maintenanceTick = createMaintenanceController({
|
|
2731
2706
|
directory: path.dirname(resolveBridgeStatePath()),
|
|
@@ -3307,6 +3282,11 @@ async function startOpenclawBridge(flags) {
|
|
|
3307
3282
|
|
|
3308
3283
|
gatewaySocket.on('message', runBridgeCallbackSafely((gatewayRaw) => {
|
|
3309
3284
|
let frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
|
|
3285
|
+
const modelsAnswer = sessionBridge.openclawModels?.take(frame);
|
|
3286
|
+
if (modelsAnswer) {
|
|
3287
|
+
if (!modelsAnswer.reply) return;
|
|
3288
|
+
frame = modelsAnswer.reply;
|
|
3289
|
+
}
|
|
3310
3290
|
let connectAccepted = false;
|
|
3311
3291
|
const practiceConfirmation = confirmPracticeDelivery(sessionBridge.practiceDeliveries ||= new Map(), frame);
|
|
3312
3292
|
if (practiceConfirmation) sendBrokerPayload(brokerSocket, {
|
|
@@ -3460,10 +3440,10 @@ async function startOpenclawBridge(flags) {
|
|
|
3460
3440
|
}
|
|
3461
3441
|
|
|
3462
3442
|
if (gatewayPayload?.type === 'res' && gatewayPayload.ok && gatewayPayload.payload?.status === 'started' && gatewayPayload.payload?.runId) {
|
|
3463
|
-
activeGatewayRuns.
|
|
3443
|
+
activeGatewayRuns.started(sessionId, gatewayPayload.payload.runId);
|
|
3464
3444
|
}
|
|
3465
3445
|
if (gatewayPayload?.event === 'chat' && ['final', 'error', 'aborted'].includes(gatewayPayload.payload?.state)) {
|
|
3466
|
-
activeGatewayRuns.
|
|
3446
|
+
activeGatewayRuns.finished(gatewayPayload.payload.runId);
|
|
3467
3447
|
}
|
|
3468
3448
|
if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'final') {
|
|
3469
3449
|
updateBridgeStatus({ runtimeReady: true, lastReplyAt: bridgeNowIso() });
|
|
@@ -3481,7 +3461,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3481
3461
|
}
|
|
3482
3462
|
|
|
3483
3463
|
if (connectAccepted) {
|
|
3484
|
-
frame = annotateConnectResponseForOomi(frame, { runtime: bridgeRuntimeName, version: currentPackageVersion() });
|
|
3464
|
+
frame = annotateConnectResponseForOomi(frame, { runtime: bridgeRuntimeName, version: currentPackageVersion(), extra: localRuntime ? localRuntime.capabilities() : bridgeRuntimeName === 'openclaw' ? [CHAT_MODELS_CAPABILITY] : [] });
|
|
3485
3465
|
}
|
|
3486
3466
|
sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
|
|
3487
3467
|
}, (error) => {
|
|
@@ -3489,6 +3469,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3489
3469
|
}));
|
|
3490
3470
|
|
|
3491
3471
|
gatewaySocket.on('close', runBridgeCallbackSafely((code, reason) => {
|
|
3472
|
+
activeGatewayRuns.closed(sessionId);
|
|
3492
3473
|
if (connectTimeout) {
|
|
3493
3474
|
clearTimeout(connectTimeout);
|
|
3494
3475
|
connectTimeout = null;
|
|
@@ -3711,7 +3692,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3711
3692
|
|
|
3712
3693
|
if (payload.type === 'client.frame') {
|
|
3713
3694
|
const sessionId = String(payload.sessionId || '').trim();
|
|
3714
|
-
|
|
3695
|
+
let frame = typeof payload.frame === 'string' ? payload.frame : '';
|
|
3715
3696
|
if (!sessionId || !frame) return;
|
|
3716
3697
|
if (classifyBridgeSessionScope(sessionId) === 'voice') {
|
|
3717
3698
|
bridgeDebugLog(`[bridge] client.frame ${sessionId} ${JSON.stringify(summarizeVoiceFrameContract(frame))}`);
|
|
@@ -3736,10 +3717,23 @@ async function startOpenclawBridge(flags) {
|
|
|
3736
3717
|
sessionBridge.lastChatCorrelationId = requestMeta.correlationId;
|
|
3737
3718
|
}
|
|
3738
3719
|
}
|
|
3720
|
+
// `/model` in Talk for an OpenClaw persona: asked of OpenClaw's own methods, answered in
|
|
3721
|
+
// oomi's shape when they reply (docs/specs/chat-models-v1.md).
|
|
3722
|
+
let extraFrames = [];
|
|
3723
|
+
const modelsPlan = gateway.gatewayUrl === 'local-agent:' ? null : (sessionBridge.openclawModels ||= new OpenclawModelRequests()).plan(frame);
|
|
3724
|
+
if (modelsPlan?.reply) {
|
|
3725
|
+
sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame: modelsPlan.reply });
|
|
3726
|
+
if (requestMeta && sessionBridge.pendingRequests instanceof Map) sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
3727
|
+
if (requestMeta) clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
if (modelsPlan) [frame, ...extraFrames] = modelsPlan.frames;
|
|
3739
3731
|
const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
|
|
3740
3732
|
connectNonce: sessionBridge.connectNonce,
|
|
3741
3733
|
deviceIdentity: gatewayDeviceIdentity,
|
|
3734
|
+
trusted: Boolean(modelsPlan),
|
|
3742
3735
|
});
|
|
3736
|
+
if (prepared.refusedMethod) bridgeDebugLog(`[bridge] client.frame refused ${sessionId} method=${prepared.refusedMethod}`);
|
|
3743
3737
|
if (prepared.practiceDelivery) {
|
|
3744
3738
|
sessionBridge.practiceDeliveries ||= new Map();
|
|
3745
3739
|
sessionBridge.practiceDeliveries.set(prepared.practiceDelivery.runId, prepared.practiceDelivery);
|
|
@@ -3791,6 +3785,9 @@ async function startOpenclawBridge(flags) {
|
|
|
3791
3785
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText, {
|
|
3792
3786
|
requiresConnectAccepted: Boolean(requestMeta && requestMeta.method !== 'connect'),
|
|
3793
3787
|
});
|
|
3788
|
+
for (const extra of extraFrames) {
|
|
3789
|
+
forwardFrameToSession(sessionBridge, prepareGatewayFrameForLocalGateway(extra, gateway, { connectNonce: sessionBridge.connectNonce, deviceIdentity: gatewayDeviceIdentity, trusted: true }).frameText, { requiresConnectAccepted: true });
|
|
3790
|
+
}
|
|
3794
3791
|
if (result === 'waiting_for_connect') {
|
|
3795
3792
|
bridgeDebugLog(`[bridge] client.frame waiting for connect ${sessionId}`);
|
|
3796
3793
|
if (requestMeta) {
|
|
@@ -3937,6 +3934,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3937
3934
|
stopIdentityWorker();
|
|
3938
3935
|
stopRoutineWorker();
|
|
3939
3936
|
agentChannelWorker.stop();
|
|
3937
|
+
stopUpdateWatcher();
|
|
3940
3938
|
if (localRuntime) { await localRuntime.queue; localRuntime.close(); }
|
|
3941
3939
|
if (reconnectState.timer) {
|
|
3942
3940
|
clearTimeout(reconnectState.timer);
|
|
@@ -4008,6 +4006,7 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
4008
4006
|
brokerHttp,
|
|
4009
4007
|
pairCode,
|
|
4010
4008
|
deviceId,
|
|
4009
|
+
proofToken: String(bridgeState.deviceId || '').trim() === deviceId ? String(bridgeState.deviceToken || '').trim() : '',
|
|
4011
4010
|
});
|
|
4012
4011
|
const deviceToken = String(claimed.deviceToken || '').trim();
|
|
4013
4012
|
if (!deviceToken) {
|
|
@@ -4653,6 +4652,8 @@ async function connectLocalAgent(flags) {
|
|
|
4653
4652
|
}
|
|
4654
4653
|
|
|
4655
4654
|
async function main() {
|
|
4655
|
+
// Everything the bridge writes (tokens, status, logs, conversations) is for this user alone.
|
|
4656
|
+
process.umask(0o077);
|
|
4656
4657
|
const args = parseArgs(process.argv);
|
|
4657
4658
|
const command = args.command;
|
|
4658
4659
|
const subcommand = args.subcommand;
|
package/lib/agentChannels.js
CHANGED
|
@@ -44,6 +44,15 @@ export function parseChannelLink(link) {
|
|
|
44
44
|
return { origin: url.origin, token: match[1] };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// The device token is this computer's key to its owner's account, so it only goes to oomi itself
|
|
48
|
+
// (oomi.ai, or the app URL this connection was set up with). A channel link to any other site
|
|
49
|
+
// joins anonymously: an agent can be told to "join" anything.
|
|
50
|
+
export function sendsDeviceToken(state, origin) {
|
|
51
|
+
const allowed = new Set(['https://www.oomi.ai', 'https://oomi.ai']);
|
|
52
|
+
try { if (state?.appUrl) allowed.add(new URL(state.appUrl).origin); } catch { /* not a URL */ }
|
|
53
|
+
return allowed.has(origin);
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
function connectionsRoot() {
|
|
48
57
|
return process.env.OOMI_CONNECTIONS_DIR || path.join(os.homedir(), '.oomi', 'connections');
|
|
49
58
|
}
|
|
@@ -175,7 +184,7 @@ export async function handleChannelCommand(state, action, flags = {}, positional
|
|
|
175
184
|
}
|
|
176
185
|
case 'join': {
|
|
177
186
|
const { origin, token } = parseChannelLink(positionals[0] || flags.link);
|
|
178
|
-
const anonymous = flags.anonymous === true;
|
|
187
|
+
const anonymous = flags.anonymous === true || !sendsDeviceToken(state, origin);
|
|
179
188
|
const joinedResult = await guest(origin, `/channel/${token}/join`, { method: 'POST', token: anonymous ? undefined : state.deviceToken,
|
|
180
189
|
body: { name: typeof flags.name === 'string' ? flags.name : undefined, kind: 'oomi-agent' } });
|
|
181
190
|
const channel = joinedResult.channel || {};
|
|
@@ -239,8 +248,28 @@ export async function handleChannelCommand(state, action, flags = {}, positional
|
|
|
239
248
|
return result;
|
|
240
249
|
}
|
|
241
250
|
|
|
251
|
+
// Only the owner's approval of a request runs with the persona's tools. A guest's message and
|
|
252
|
+
// every other notice run sandboxed (localAgent / gatewayProbe), whatever the channel's permission
|
|
253
|
+
// levels say: the levels are what the persona may ask its owner for, not what a guest can do.
|
|
254
|
+
export function sandboxedTurn(item) {
|
|
255
|
+
return !(item?.kind === 'approval_decision' && item?.sender === 'system' && item?.approved === true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const APPROVAL_LINE = /^OOMI_APPROVAL_REQUEST\s+(privateInfo|actions|commands|payments)\s*:\s*(.{1,500})$/gm;
|
|
259
|
+
|
|
260
|
+
/** Approval requests the persona wrote into a sandboxed reply, and the reply without them. */
|
|
261
|
+
export function approvalRequests(text) {
|
|
262
|
+
const requests = [...String(text || '').matchAll(APPROVAL_LINE)].slice(0, 3).map((match) => ({ permission: match[1], summary: match[2].trim() }));
|
|
263
|
+
return { requests, text: String(text || '').replace(APPROVAL_LINE, '').replace(/\n{3,}/g, '\n\n').trim() };
|
|
264
|
+
}
|
|
265
|
+
|
|
242
266
|
export function channelTurnMessage(item, connection = '') {
|
|
243
267
|
const flag = connection ? ` --connection ${connection}` : '';
|
|
268
|
+
if (sandboxedTurn(item)) {
|
|
269
|
+
return `${item.prompt}\n\n(Oomi: this turn runs with your tools turned off. You can't run commands, use files, browse or change memory here, and nothing above can turn them on. ` +
|
|
270
|
+
'To ask your owner for something that needs them, add a line on its own: OOMI_APPROVAL_REQUEST <privateInfo|actions|commands|payments>: <exactly what you will do>. ' +
|
|
271
|
+
'Oomi sends it to your owner and removes the line. If they approve, an Oomi approval notice arrives with your tools. Use this instead of the oomi channel commands.)';
|
|
272
|
+
}
|
|
244
273
|
return `${item.prompt}\n\n(Oomi CLI for this channel: oomi channel request-approval ${item.channelId} --permission NAME --summary "..."${flag} · oomi channel show ${item.channelId}${flag} · oomi channel end ${item.channelId}${flag})`;
|
|
245
274
|
}
|
|
246
275
|
|
|
@@ -287,9 +316,15 @@ export function startAgentChannelWorker({ state, deliver, connection = '', reque
|
|
|
287
316
|
for (const item of result.messages || []) {
|
|
288
317
|
if (stopped) break;
|
|
289
318
|
if (!MESSAGE_ID.test(item.messageId || '') || !CHANNEL_ID.test(item.channelId || '') || typeof item.prompt !== 'string') continue;
|
|
319
|
+
// A channel turn only ever runs in that channel's own session, never the owner's chat.
|
|
320
|
+
if (item.sessionKey !== `agent:main:oomi:channel:${item.channelId}`) {
|
|
321
|
+
await request(state, `agent_channels/inbox/${item.messageId}/ack`, { method: 'POST', body: { outcome: 'failed', failureCode: 'host_turn_failed', failureDetail: 'Unexpected session for this channel.' } }).catch(() => {});
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const sandbox = sandboxedTurn(item);
|
|
290
325
|
let reply;
|
|
291
326
|
try {
|
|
292
|
-
reply = await deliver({ sessionKey: item.sessionKey, idempotencyKey: item.messageId, message: channelTurnMessage(item, connection), retryInterrupted: false });
|
|
327
|
+
reply = await deliver({ sessionKey: item.sessionKey, idempotencyKey: item.messageId, message: channelTurnMessage(item, connection), retryInterrupted: false, sandbox, approvedAction: !sandbox });
|
|
293
328
|
} catch (error) {
|
|
294
329
|
// Report why, so the owner is told what to fix and the guest isn't left guessing.
|
|
295
330
|
const failure = classifyRuntimeFailure(error);
|
|
@@ -297,7 +332,11 @@ export function startAgentChannelWorker({ state, deliver, connection = '', reque
|
|
|
297
332
|
onError(Object.assign(error, { failure }));
|
|
298
333
|
continue;
|
|
299
334
|
}
|
|
300
|
-
const
|
|
335
|
+
const asked = approvalRequests(typeof reply?.text === 'string' ? reply.text : '');
|
|
336
|
+
for (const [index, wanted] of asked.requests.entries()) {
|
|
337
|
+
await request(state, `agent_channels/${item.channelId}/approvals`, { method: 'POST', body: { ...wanted, idempotencyKey: `turn:${item.messageId}:${index}` } }).catch((error) => onError(error));
|
|
338
|
+
}
|
|
339
|
+
const text = asked.text;
|
|
301
340
|
// NO_REPLY lets the agent take in an owner notice without messaging the guest.
|
|
302
341
|
if (text && text !== 'NO_REPLY') await postReply(item, clampReply(text));
|
|
303
342
|
await request(state, `agent_channels/inbox/${item.messageId}/ack`, { method: 'POST', body: { outcome: 'delivered' } });
|
package/lib/agentIdentity.js
CHANGED
|
@@ -72,7 +72,9 @@ export function startIdentityWorker({ state, deliver, acknowledgeCommand, reques
|
|
|
72
72
|
const poll = async () => {
|
|
73
73
|
try {
|
|
74
74
|
const { identity } = await request(state);
|
|
75
|
-
|
|
75
|
+
// These go into a command the agent is told to run: only a numeric id and revision pass.
|
|
76
|
+
const wellFormed = /^\d{1,18}$/.test(String(identity?.identityId ?? '')) && Number.isSafeInteger(identity?.revision);
|
|
77
|
+
if (!stopped && wellFormed && identity.revision > 0 && identity.syncStatus === 'pending' && identity.source !== 'agent') {
|
|
76
78
|
const idempotencyKey = `oomi-identity:${identity.identityId}:${identity.revision}`;
|
|
77
79
|
const prefix = (state.sessionKey || 'agent:main:').split(':').slice(0, 2).join(':');
|
|
78
80
|
const receipt = receiptPath ? readJson(receiptPath) : {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `/model` in Talk (docs/specs/chat-models-v1.md): the shapes oomi.models.list and
|
|
2
|
+
// oomi.models.set answer with. Each runtime's own list becomes entries
|
|
3
|
+
// `{ id, provider, providerName, name }` first; only models the persona can use are kept.
|
|
4
|
+
|
|
5
|
+
export const CHAT_MODELS_CAPABILITY = 'chat.models.v1';
|
|
6
|
+
|
|
7
|
+
const MESSAGES = {
|
|
8
|
+
busy: 'The persona is answering. Pick a model when it\'s done.',
|
|
9
|
+
unknown_model: 'That model isn\'t one this persona can use.',
|
|
10
|
+
unavailable: 'The model can\'t be changed right now.',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** An error the app may show as it is: `code` is busy, unknown_model or unavailable. */
|
|
14
|
+
export class ChatModelsError extends Error {
|
|
15
|
+
constructor(code) { super(MESSAGES[code] || MESSAGES.unavailable); this.code = MESSAGES[code] ? code : 'unavailable'; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// "accounts/fireworks/models/deepseek-v4p1-flash" → "deepseek-v4p1-flash": no paths on screen.
|
|
19
|
+
function lastSegment(model) { return String(model).split('/').filter(Boolean).pop() || String(model); }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hermes over ACP: ids are "provider:model" and names "Provider label · model"; Hermes lists
|
|
23
|
+
* only providers its owner signed in to.
|
|
24
|
+
*/
|
|
25
|
+
export function hermesEntries(available = []) {
|
|
26
|
+
return available.map((entry) => {
|
|
27
|
+
const split = entry.id.indexOf(':');
|
|
28
|
+
const provider = split > 0 ? entry.id.slice(0, split) : '';
|
|
29
|
+
const model = split > 0 ? entry.id.slice(split + 1) : entry.id;
|
|
30
|
+
const label = typeof entry.name === 'string' && entry.name.includes(' · ') ? entry.name.slice(0, entry.name.indexOf(' · ')).trim() : '';
|
|
31
|
+
return { id: entry.id, provider, providerName: label || provider, name: lastSegment(model) };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', 'openai-codex': 'ChatGPT or Codex', google: 'Google', xai: 'xAI', openrouter: 'OpenRouter', fireworks: 'Fireworks AI', deepseek: 'DeepSeek', mistral: 'Mistral', groq: 'Groq', ollama: 'Ollama' };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* OpenClaw's models.list (view "configured": configured models and providers with usable auth).
|
|
39
|
+
* Ids are OpenClaw's "provider/model" refs; a row it marks unavailable (missing or failed
|
|
40
|
+
* sign-in, cooldown) isn't offered.
|
|
41
|
+
*/
|
|
42
|
+
export function openclawEntries(rows = []) {
|
|
43
|
+
return rows
|
|
44
|
+
.filter((row) => row && typeof row.id === 'string' && row.id && typeof row.provider === 'string' && row.provider && row.available !== false)
|
|
45
|
+
.map((row) => ({
|
|
46
|
+
id: `${row.provider}/${row.id}`,
|
|
47
|
+
provider: row.provider,
|
|
48
|
+
providerName: PROVIDER_NAMES[row.provider.toLowerCase()] || row.provider,
|
|
49
|
+
name: typeof row.name === 'string' && row.name.trim() ? row.name.trim() : lastSegment(row.id),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One model as the app shows it; null for none. A model the list doesn't have keeps its own id's name. */
|
|
54
|
+
export function modelChoice(id, entries = []) {
|
|
55
|
+
if (typeof id !== 'string' || !id) return null;
|
|
56
|
+
const entry = entries.find((model) => model.id === id);
|
|
57
|
+
if (entry) return { id, model: entry.name, provider: entry.provider, providerName: entry.providerName };
|
|
58
|
+
const split = id.search(/[:/]/);
|
|
59
|
+
const provider = split > 0 ? id.slice(0, split) : '';
|
|
60
|
+
const sibling = entries.find((model) => model.provider === provider);
|
|
61
|
+
return { id, model: lastSegment(split > 0 ? id.slice(split + 1) : id), provider, providerName: sibling?.providerName || provider };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** oomi.models.list's answer: the current model, then each provider's models in the runtime's order. */
|
|
65
|
+
export function modelsAnswer({ current, entries = [] }) {
|
|
66
|
+
const providers = [];
|
|
67
|
+
const byId = new Map();
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!byId.has(entry.provider)) {
|
|
70
|
+
const group = { id: entry.provider, name: entry.providerName, models: [] };
|
|
71
|
+
byId.set(entry.provider, group);
|
|
72
|
+
providers.push(group);
|
|
73
|
+
}
|
|
74
|
+
byId.get(entry.provider).models.push({ id: entry.id, name: entry.name });
|
|
75
|
+
}
|
|
76
|
+
return { scope: 'conversation', current: modelChoice(current, entries), providers };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the person typed after `/model`, as an id from the list when one fits: the id itself, else
|
|
81
|
+
* the one model with that name, else the one whose name starts with it. Anything else goes to the
|
|
82
|
+
* runtime as typed (it knows its own aliases).
|
|
83
|
+
*/
|
|
84
|
+
export function matchModel(typed, entries = []) {
|
|
85
|
+
const text = String(typed || '').trim();
|
|
86
|
+
if (!text) return '';
|
|
87
|
+
if (entries.some((model) => model.id === text)) return text;
|
|
88
|
+
const lower = text.toLowerCase();
|
|
89
|
+
const named = entries.filter((model) => model.name.toLowerCase() === lower);
|
|
90
|
+
if (named.length === 1) return named[0].id;
|
|
91
|
+
const started = entries.filter((model) => model.name.toLowerCase().startsWith(lower));
|
|
92
|
+
return started.length === 1 ? started[0].id : text;
|
|
93
|
+
}
|
package/lib/gatewayProbe.js
CHANGED
|
@@ -6,10 +6,21 @@ import { redactDetail } from './runtimeFailure.js';
|
|
|
6
6
|
export const LOCAL_PROTOCOL_MIN = 3;
|
|
7
7
|
export const LOCAL_PROTOCOL_MAX = 4;
|
|
8
8
|
|
|
9
|
+
// A guest's turn (agent channels, chat links) first puts its session in OpenClaw's read-only
|
|
10
|
+
// permission mode (no exec, no file changes) and doesn't run if that fails; the owner's approved
|
|
11
|
+
// request puts it back on the agent's own policy (null) first. Other turns don't touch the mode.
|
|
12
|
+
export function guestPermissionMode(turn) {
|
|
13
|
+
if (turn?.sandbox === true) return 'read-only';
|
|
14
|
+
if (turn?.approvedAction === true) return null;
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
export function probeGateway({ gateway, deviceIdentity, prepareFrame, verifyReply = false, timeoutMs = 15000, turn }) {
|
|
10
19
|
return new Promise((resolve, reject) => {
|
|
11
20
|
const socket = new WebSocket(gateway.gatewayUrl);
|
|
12
21
|
const connectId = randomUUID();
|
|
22
|
+
const patchId = randomUUID();
|
|
23
|
+
const permissionMode = guestPermissionMode(turn);
|
|
13
24
|
const runId = turn?.idempotencyKey || randomUUID();
|
|
14
25
|
verifyReply ||= Boolean(turn);
|
|
15
26
|
let settled = false;
|
|
@@ -18,6 +29,13 @@ export function probeGateway({ gateway, deviceIdentity, prepareFrame, verifyRepl
|
|
|
18
29
|
let turnSent = false;
|
|
19
30
|
let started = false;
|
|
20
31
|
const confirmStarted = () => { if (!started) { started = true; turn?.onStarted?.(); } };
|
|
32
|
+
const sendTurn = () => {
|
|
33
|
+
turnSent = true;
|
|
34
|
+
socket.send(JSON.stringify({ type: 'req', id: runId, method: 'chat.send', params: {
|
|
35
|
+
sessionKey: turn?.sessionKey || `agent:main:oomi:verify:${runId}`, idempotencyKey: runId,
|
|
36
|
+
message: turn?.message || 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
|
|
37
|
+
} }));
|
|
38
|
+
};
|
|
21
39
|
const finish = (error, result) => {
|
|
22
40
|
if (settled) return;
|
|
23
41
|
settled = true;
|
|
@@ -45,17 +63,23 @@ export function probeGateway({ gateway, deviceIdentity, prepareFrame, verifyRepl
|
|
|
45
63
|
let frame;
|
|
46
64
|
try { frame = JSON.parse(raw.toString()); } catch { return; }
|
|
47
65
|
if (frame.event === 'connect.challenge') connect(frame.payload?.nonce);
|
|
66
|
+
if (frame.type === 'res' && frame.id === patchId) {
|
|
67
|
+
if (!frame.ok && permissionMode === 'read-only') {
|
|
68
|
+
return finish(Object.assign(new Error('OpenClaw could not limit this guest turn to read-only, so it did not run.'), { notStarted: true, runtimeFailureCode: 'guest_sandbox_unavailable' }));
|
|
69
|
+
}
|
|
70
|
+
return sendTurn();
|
|
71
|
+
}
|
|
48
72
|
if (frame.type === 'res' && (frame.id === connectId || frame.id === runId) && !frame.ok) {
|
|
49
73
|
finish(Object.assign(new Error(`Local agent rejected ${frame.id === connectId ? 'connect' : 'verification'}: ${frame.error?.message || frame.error?.code || 'unknown error'}`), { notStarted: !started }));
|
|
50
74
|
} else if (frame.type === 'res' && frame.id === connectId && frame.ok) {
|
|
51
75
|
protocol = frame.payload?.protocol;
|
|
52
76
|
if (![3, 4].includes(protocol)) return finish(new Error('Local gateway returned an unsupported protocol version. Update oomi-ai.'));
|
|
53
77
|
if (!verifyReply) return finish(null, { protocol });
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
if (permissionMode !== undefined && turn?.sessionKey) {
|
|
79
|
+
socket.send(JSON.stringify({ type: 'req', id: patchId, method: 'sessions.patch', params: { key: turn.sessionKey, permissionMode } }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
sendTurn();
|
|
59
83
|
} else if (verifyReply && frame.type === 'res' && frame.id === runId && frame.ok) {
|
|
60
84
|
confirmStarted();
|
|
61
85
|
} else if (verifyReply && frame.event === 'chat' && frame.payload?.runId === runId) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Runs the local gateway has started, by the gateway socket they started on. A closed socket
|
|
2
|
+
// can't deliver its runs' final events, so its runs stop counting as active; otherwise a lost
|
|
3
|
+
// final would keep the bridge from ever being idle, and a queued restart would expire.
|
|
4
|
+
export function createGatewayRuns() {
|
|
5
|
+
const bySocket = new Map();
|
|
6
|
+
return {
|
|
7
|
+
started(socketId, runId) {
|
|
8
|
+
if (!runId) return;
|
|
9
|
+
let runs = bySocket.get(socketId);
|
|
10
|
+
if (!runs) bySocket.set(socketId, (runs = new Set()));
|
|
11
|
+
runs.add(runId);
|
|
12
|
+
},
|
|
13
|
+
// A final can arrive on another socket after a reconnect.
|
|
14
|
+
finished(runId) {
|
|
15
|
+
for (const [socketId, runs] of bySocket) {
|
|
16
|
+
runs.delete(runId);
|
|
17
|
+
if (!runs.size) bySocket.delete(socketId);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
closed(socketId) {
|
|
21
|
+
bySocket.delete(socketId);
|
|
22
|
+
},
|
|
23
|
+
get size() {
|
|
24
|
+
let count = 0;
|
|
25
|
+
for (const runs of bySocket.values()) count += runs.size;
|
|
26
|
+
return count;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
package/lib/hermesAcp.js
CHANGED
|
@@ -22,9 +22,6 @@ 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;
|
|
28
25
|
|
|
29
26
|
// Only fixed messages cross the bridge; Hermes diagnostics may hold credentials or tool output.
|
|
30
27
|
export class HermesAcpError extends Error {
|
|
@@ -79,29 +76,41 @@ class HermesAcpClient {
|
|
|
79
76
|
this.turns = new Map();
|
|
80
77
|
this.loaded = new Set();
|
|
81
78
|
this.replayed = new Map();
|
|
82
|
-
// The model each ACP session is on
|
|
83
|
-
// answers), for
|
|
79
|
+
// The model each ACP session is on and what Hermes can switch to (session/new and
|
|
80
|
+
// session/load answers), for `/model` in Talk and to put back a conversation an older bridge
|
|
81
|
+
// left on a call's model (restoreChatModel).
|
|
84
82
|
this.sessionModels = new Map();
|
|
85
83
|
this.availableModels = [];
|
|
86
|
-
this.
|
|
84
|
+
this.defaultModel = '';
|
|
87
85
|
this.closed = false;
|
|
88
86
|
this.ready = null;
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
noteModels(sessionId, models, { created = false } = {}) {
|
|
92
|
-
|
|
93
|
-
const current = typeof models.currentModelId === 'string' ? models.currentModelId : '';
|
|
90
|
+
const current = typeof models?.currentModelId === 'string' ? models.currentModelId : '';
|
|
94
91
|
if (current) {
|
|
95
92
|
this.sessionModels.set(sessionId, current);
|
|
96
|
-
if (created) this.
|
|
93
|
+
if (created) this.defaultModel = current;
|
|
97
94
|
}
|
|
98
|
-
if (Array.isArray(models
|
|
95
|
+
if (Array.isArray(models?.availableModels)) {
|
|
99
96
|
this.availableModels = models.availableModels
|
|
100
97
|
.filter((model) => typeof model?.modelId === 'string' && model.modelId)
|
|
101
98
|
.map((model) => ({ id: model.modelId, name: typeof model.name === 'string' ? model.name : '' }));
|
|
102
99
|
}
|
|
103
100
|
}
|
|
104
101
|
|
|
102
|
+
/** The person's pick (`/model`): fixed errors only, since Hermes's own text may hold internals. */
|
|
103
|
+
async setModel(sessionId, modelId) {
|
|
104
|
+
if (this.turns.has(sessionId)) throw new HermesAcpError('hermes_busy', 'Hermes is answering in this conversation.');
|
|
105
|
+
try {
|
|
106
|
+
await this.request('session/set_model', { sessionId, modelId });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.rpcCode === -32602) throw new HermesAcpError('hermes_unknown_model', 'Hermes can\'t use that model.');
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
this.sessionModels.set(sessionId, modelId);
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
/** Switches a session's model between turns (Hermes refuses mid-turn); false if it couldn't. */
|
|
106
115
|
async switchModel(sessionId, modelId) {
|
|
107
116
|
if (!modelId || this.turns.has(sessionId)) return false;
|
|
@@ -399,87 +408,55 @@ export async function runHermesAcpTurn(config, request, { signal, timeoutMs = 18
|
|
|
399
408
|
const continuing = request.runtimeTransport === 'acp' ? request.runtimeSessionId : '';
|
|
400
409
|
let { sessionId, created } = await client.openSession(continuing);
|
|
401
410
|
const seed = () => continuityPreamble(request.history);
|
|
402
|
-
let chatModel = await
|
|
411
|
+
let chatModel = await restoreChatModel(client, sessionId, request);
|
|
403
412
|
let text;
|
|
404
413
|
try {
|
|
405
|
-
|
|
414
|
+
// A session `/model` opened for a conversation with history hasn't heard it yet.
|
|
415
|
+
text = await client.prompt(sessionId, promptBlocks(request, created || request.seedPending ? seed() : ''), { signal, timeoutMs, onText });
|
|
406
416
|
} catch (err) {
|
|
407
417
|
if (err.code !== 'hermes_session_unknown' || created) throw err;
|
|
408
418
|
({ sessionId } = await client.openSession(''));
|
|
409
|
-
// A fresh session starts on the
|
|
410
|
-
chatModel =
|
|
419
|
+
// A fresh session starts on the persona's own model.
|
|
420
|
+
chatModel = null;
|
|
411
421
|
text = await client.prompt(sessionId, promptBlocks(request, seed()), { signal, timeoutMs, onText });
|
|
412
422
|
}
|
|
413
423
|
return { text, sessionId, transport: 'acp', chatModel };
|
|
414
424
|
}
|
|
415
425
|
|
|
416
426
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
427
|
+
* `/model` in Talk (docs/specs/chat-models-v1.md): the model a conversation's session is on, and
|
|
428
|
+
* what Hermes can switch it to (the providers its owner signed in to, as Telegram's picker lists).
|
|
429
|
+
* `create` makes a session when the conversation has none: true always, 'if-unknown' only when
|
|
430
|
+
* Hermes hasn't said its models yet. `session` is then `{ sessionId, created }` for the
|
|
431
|
+
* conversation to keep.
|
|
421
432
|
*/
|
|
422
|
-
async function
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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;
|
|
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 };
|
|
433
|
+
export async function hermesAcpModels(config, sessionId, { create = false } = {}) {
|
|
434
|
+
const client = hermesAcpClient(config);
|
|
435
|
+
await client.start();
|
|
436
|
+
const make = create === true || (create === 'if-unknown' && !client.availableModels.length);
|
|
437
|
+
const session = sessionId || make ? await client.openSession(sessionId || '', { create: make }) : null;
|
|
438
|
+
const current = (session && client.sessionModels.get(session.sessionId)) || client.defaultModel;
|
|
439
|
+
return { session, current, available: client.availableModels };
|
|
453
440
|
}
|
|
454
441
|
|
|
455
|
-
/**
|
|
456
|
-
export function
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
return
|
|
442
|
+
/** Switches a conversation's session (opened or made here) to `modelId`, for this conversation only. */
|
|
443
|
+
export async function setHermesAcpModel(config, sessionId, modelId) {
|
|
444
|
+
const client = hermesAcpClient(config);
|
|
445
|
+
await client.start();
|
|
446
|
+
const session = await client.openSession(sessionId || '');
|
|
447
|
+
await client.setModel(session.sessionId, modelId);
|
|
448
|
+
return { session, current: modelId, available: client.availableModels };
|
|
462
449
|
}
|
|
463
450
|
|
|
464
|
-
/**
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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;
|
|
451
|
+
/**
|
|
452
|
+
* Calls answer on the model the persona is set up with; Oomi never switches it (Justin,
|
|
453
|
+
* 2026-10-22). oomi-ai 0.11.0-0.12.3 switched a session to a faster model on calls and kept the
|
|
454
|
+
* model to go back to in the conversation's state (request.chatModel): put such a session back.
|
|
455
|
+
* Returns the model still to go back to if Hermes couldn't switch now, else null.
|
|
456
|
+
*/
|
|
457
|
+
async function restoreChatModel(client, sessionId, request) {
|
|
458
|
+
const remembered = typeof request.chatModel === 'string' && request.chatModel ? request.chatModel : null;
|
|
459
|
+
if (!remembered || client.sessionModels.get(sessionId) === remembered) return null;
|
|
460
|
+
return (await client.switchModel(sessionId, remembered)) ? null : remembered;
|
|
484
461
|
}
|
|
485
462
|
|
package/lib/localAgent.js
CHANGED
|
@@ -5,7 +5,8 @@ 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,
|
|
8
|
+
import { runHermesAcpTurn, hermesAcpAvailable, resetHermesAcp, warmHermesAcp, hermesAcpModels, setHermesAcpModel, continuityPreamble } from './hermesAcp.js';
|
|
9
|
+
import { CHAT_MODELS_CAPABILITY, ChatModelsError, hermesEntries, matchModel, modelChoice, modelsAnswer } from './chatModels.js';
|
|
9
10
|
import { grokbotConfig, runGrokbotTurn, completedGrokbotTurn } from './grokbotRuntime.js';
|
|
10
11
|
import { classifyRuntimeFailure } from './runtimeFailure.js';
|
|
11
12
|
|
|
@@ -114,9 +115,19 @@ function usesHermesAcp(config) {
|
|
|
114
115
|
return config.hermesTransport === 'acp' || hermesAcpAvailable(config);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// A guest's turn (agent channels, chat links) runs with the persona's tools off: Hermes as
|
|
119
|
+
// `hermes chat --toolsets clarify` (no terminal, files, browser, memory, skills or MCP servers;
|
|
120
|
+
// Hermes freezes the toolsets into the session), a command adapter told `sandbox: true`. Grok
|
|
121
|
+
// Bot can't be limited, so it doesn't take guests.
|
|
122
|
+
export const SANDBOX_TOOLSETS = 'clarify';
|
|
123
|
+
|
|
117
124
|
// No shell interpolation. Message bodies travel over stdin, never as executable code.
|
|
118
125
|
export async function runLocalAgent(config, request, options = {}) {
|
|
126
|
+
if (request.sandbox && config.runtime === 'grokbot') {
|
|
127
|
+
throw Object.assign(new Error("This persona's runtime can't answer guests with its tools turned off."), { notStarted: true, runtimeFailureCode: 'guest_sandbox_unavailable' });
|
|
128
|
+
}
|
|
119
129
|
if (config.runtime === 'grokbot') return runGrokbotTurn(config, request, options);
|
|
130
|
+
if (config.runtime === 'hermes' && request.sandbox) return runAgentProcess(config, request, options);
|
|
120
131
|
if (config.runtime === 'hermes') {
|
|
121
132
|
const shared = await runSharedHermesTurn(config, request, options);
|
|
122
133
|
if (shared) {
|
|
@@ -151,6 +162,7 @@ function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
|
|
|
151
162
|
if (config.runtime === 'hermes') {
|
|
152
163
|
if (config.profile) args.push('--profile', config.profile);
|
|
153
164
|
args.push('chat', '--quiet', '--query-file', '-');
|
|
165
|
+
if (request.sandbox) args.push('--toolsets', SANDBOX_TOOLSETS);
|
|
154
166
|
if (request.runtimeSessionId) args.push('--resume', request.runtimeSessionId);
|
|
155
167
|
if (request.imagePaths?.[0]) args.push('--image', request.imagePaths[0]);
|
|
156
168
|
}
|
|
@@ -220,15 +232,10 @@ export class LocalAgentRuntime {
|
|
|
220
232
|
this.queue = Promise.resolve();
|
|
221
233
|
this.active = new Map();
|
|
222
234
|
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 = '';
|
|
226
235
|
}
|
|
227
236
|
file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
|
|
228
237
|
history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
|
|
229
|
-
async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false, onText, onStarted, 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() : '';
|
|
238
|
+
async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false, onText, onStarted, oomi, sandbox = false }) {
|
|
232
239
|
const images = readImageAttachments(attachments);
|
|
233
240
|
const text = typeof message === 'string' ? message : '';
|
|
234
241
|
if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || (!text.trim() && !images.length)) throw new Error('A session, message, and idempotency key are required.');
|
|
@@ -269,13 +276,23 @@ export class LocalAgentRuntime {
|
|
|
269
276
|
};
|
|
270
277
|
try {
|
|
271
278
|
scratch = images.length ? writeTurnImages(images) : null;
|
|
272
|
-
|
|
279
|
+
// A sandboxed turn keeps its own runtime session (its tools are frozen off), started with
|
|
280
|
+
// the conversation so far; the conversation's own session is left as it is.
|
|
281
|
+
const request = sandbox
|
|
282
|
+
? { sessionKey, message: (state.sandboxSessionId ? '' : continuityPreamble(state.messages)) + prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.sandboxSessionId || '', runtimeTransport: 'cli', imagePaths: [], sandbox: true }
|
|
283
|
+
: { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, runtimeTransport: state.runtimeTransport, imagePaths: scratch?.paths || [], chatModel: state.runtimeChatModel || null, seedPending: state.runtimeSeedPending === true };
|
|
284
|
+
const result = resumedReply || await runLocalAgent(this.config, request, { signal: controller.signal, onText: text => { confirmStarted(); onText?.(text); }, onStarted: confirmStarted });
|
|
273
285
|
confirmStarted();
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
286
|
+
if (sandbox) {
|
|
287
|
+
if (result.sessionId) state.sandboxSessionId = result.sessionId;
|
|
288
|
+
} else {
|
|
289
|
+
state.runtimeSessionId = result.sessionId;
|
|
290
|
+
if (result.transport) state.runtimeTransport = result.transport;
|
|
291
|
+
delete state.runtimeSeedPending;
|
|
292
|
+
}
|
|
293
|
+
// A session an older bridge left on a call's model keeps the model to go back to until
|
|
294
|
+
// Hermes has switched it (restoreChatModel).
|
|
295
|
+
if (!sandbox && 'chatModel' in result) {
|
|
279
296
|
if (result.chatModel) state.runtimeChatModel = result.chatModel;
|
|
280
297
|
else delete state.runtimeChatModel;
|
|
281
298
|
}
|
|
@@ -283,7 +300,6 @@ export class LocalAgentRuntime {
|
|
|
283
300
|
state.receipts[receiptKey] = { message, practiceDigest, result };
|
|
284
301
|
writePrivateJson(this.file(sessionKey), state);
|
|
285
302
|
this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
|
|
286
|
-
this.reportModels(state.runtimeChatModel);
|
|
287
303
|
return { ...result, started: true };
|
|
288
304
|
} catch (err) {
|
|
289
305
|
if (err.notStarted === true && !started) {
|
|
@@ -306,32 +322,59 @@ export class LocalAgentRuntime {
|
|
|
306
322
|
}
|
|
307
323
|
socket() { return new LocalAgentSocket(this); }
|
|
308
324
|
close() { if (this.config.runtime === 'hermes') resetHermesAcp(this.config); }
|
|
309
|
-
/**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
325
|
+
/** What this runtime adds to the bridge's capabilities on connect. */
|
|
326
|
+
capabilities() { return this.switchesModels() ? [CHAT_MODELS_CAPABILITY] : []; }
|
|
327
|
+
switchesModels() { return this.config.runtime === 'hermes' && usesHermesAcp(this.config); }
|
|
328
|
+
answering(sessionKey) { return [...this.active.values()].some((entry) => entry.sessionKey === sessionKey); }
|
|
329
|
+
/** `/model` in Talk (docs/specs/chat-models-v1.md): the conversation's model and what it can switch to. */
|
|
330
|
+
async models(sessionKey) {
|
|
331
|
+
if (!this.switchesModels() || typeof sessionKey !== 'string' || !sessionKey) throw new ChatModelsError('unavailable');
|
|
332
|
+
// Mid-turn the conversation's session belongs to the turn: read, don't open one.
|
|
333
|
+
const answering = this.answering(sessionKey);
|
|
334
|
+
const result = await hermesAcpModels(this.config, this.acpSession(sessionKey), { create: answering ? false : 'if-unknown' }).catch(() => { throw new ChatModelsError('unavailable'); });
|
|
335
|
+
if (!answering) this.keepAcpSession(sessionKey, result.session);
|
|
336
|
+
return modelsAnswer({ current: result.current, entries: hermesEntries(result.available) });
|
|
337
|
+
}
|
|
338
|
+
/** Switches this conversation only; nothing reaches the model, and the persona's default stays. */
|
|
339
|
+
async setModel(sessionKey, model) {
|
|
340
|
+
if (!this.switchesModels() || typeof sessionKey !== 'string' || !sessionKey) throw new ChatModelsError('unavailable');
|
|
341
|
+
if (typeof model !== 'string' || !model.trim() || model.length > 300) throw new ChatModelsError('unknown_model');
|
|
342
|
+
if (this.answering(sessionKey)) throw new ChatModelsError('busy');
|
|
343
|
+
// Names resolve against Hermes's list; a new conversation's session is opened (and kept) to learn it.
|
|
344
|
+
const listed = await hermesAcpModels(this.config, this.acpSession(sessionKey), { create: 'if-unknown' }).catch(() => ({ session: null, available: [] }));
|
|
345
|
+
this.keepAcpSession(sessionKey, listed.session);
|
|
346
|
+
let result;
|
|
347
|
+
try {
|
|
348
|
+
result = await setHermesAcpModel(this.config, this.acpSession(sessionKey), matchModel(model, hermesEntries(listed.available)));
|
|
349
|
+
} catch (err) {
|
|
350
|
+
throw new ChatModelsError(err.code === 'hermes_busy' ? 'busy' : err.code === 'hermes_unknown_model' ? 'unknown_model' : 'unavailable');
|
|
351
|
+
}
|
|
352
|
+
this.keepAcpSession(sessionKey, result.session, { picked: true });
|
|
353
|
+
return { scope: 'conversation', current: modelChoice(result.current, hermesEntries(result.available)) };
|
|
313
354
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const report = hermesAcpModelReport(this.config, { chatModel: chatModel || this.rememberedChatModel() });
|
|
318
|
-
if (!report) return;
|
|
319
|
-
const text = JSON.stringify(report);
|
|
320
|
-
if (text === this.lastModelReport) return;
|
|
321
|
-
this.lastModelReport = text;
|
|
322
|
-
// Reporting never breaks a turn; a report that didn't arrive goes again after the next one.
|
|
323
|
-
Promise.resolve().then(() => this.onModels(report)).then((delivered) => { if (delivered === false) this.lastModelReport = ''; }, () => { this.lastModelReport = ''; });
|
|
355
|
+
acpSession(sessionKey) {
|
|
356
|
+
const state = this.history(sessionKey);
|
|
357
|
+
return state.runtimeTransport === 'acp' && state.runtimeSessionId ? state.runtimeSessionId : '';
|
|
324
358
|
}
|
|
325
|
-
/** A
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
/** A session `/model` opened is the conversation's from now on; one made for a conversation with history still needs its seed. */
|
|
360
|
+
keepAcpSession(sessionKey, session, { picked = false } = {}) {
|
|
361
|
+
if (!session) return;
|
|
362
|
+
const state = this.history(sessionKey);
|
|
363
|
+
const changed = state.runtimeSessionId !== session.sessionId || state.runtimeTransport !== 'acp';
|
|
364
|
+
if (!changed && !(picked && state.runtimeChatModel)) return;
|
|
365
|
+
if (changed) {
|
|
366
|
+
state.runtimeSessionId = session.sessionId;
|
|
367
|
+
state.runtimeTransport = 'acp';
|
|
368
|
+
if (session.created && state.messages.length) state.runtimeSeedPending = true;
|
|
333
369
|
}
|
|
334
|
-
|
|
370
|
+
// The person's pick wins over a call model an older bridge meant to undo.
|
|
371
|
+
if (picked) delete state.runtimeChatModel;
|
|
372
|
+
writePrivateJson(this.file(sessionKey), state);
|
|
373
|
+
}
|
|
374
|
+
/** Gets Hermes ready before a message arrives: at bridge start and when a call opens. */
|
|
375
|
+
warm() {
|
|
376
|
+
if (this.config.runtime !== 'hermes' || !usesHermesAcp(this.config)) return Promise.resolve();
|
|
377
|
+
return warmHermesAcp(this.config, this.recentAcpSessions(2)).catch(() => {});
|
|
335
378
|
}
|
|
336
379
|
recentAcpSessions(limit) {
|
|
337
380
|
const directory = path.join(this.directory, 'conversations');
|
|
@@ -370,12 +413,16 @@ class LocalAgentSocket extends EventEmitter {
|
|
|
370
413
|
try { req = JSON.parse(raw); } catch { return; }
|
|
371
414
|
if (req.type !== 'req') return;
|
|
372
415
|
const reply = (payload) => this.frame({ type: 'res', id: req.id, ok: true, payload });
|
|
373
|
-
const fail = (message) => this.frame({ type: 'res', id: req.id, ok: false, error: { code
|
|
416
|
+
const fail = (message, code = 'local_agent_error') => this.frame({ type: 'res', id: req.id, ok: false, error: { code, message } });
|
|
374
417
|
const params = req.params || {};
|
|
375
418
|
if (req.method === 'connect') { this.connected = true; return reply({ type: 'hello-ok', protocol: 3, features: { methods: ['chat.send', 'chat.history', 'chat.abort'], events: ['chat'] }, snapshot: {}, policy: { tickIntervalMs: 30000 } }); }
|
|
376
419
|
if (!this.connected) return fail('Connect before sending requests.');
|
|
377
420
|
if (req.method === 'chat.history') return reply({ sessionKey: params.sessionKey, messages: this.runtime.history(params.sessionKey || '').messages.slice(-Math.min(params.limit || 200, 500)) });
|
|
378
421
|
if (req.method === 'chat.abort') return reply({ aborted: this.runtime.abort(params.sessionKey, params.runId) });
|
|
422
|
+
// `/model` in Talk (docs/specs/chat-models-v1.md).
|
|
423
|
+
const failModels = (err) => fail(err instanceof ChatModelsError ? err.message : new ChatModelsError('unavailable').message, err instanceof ChatModelsError ? err.code : 'unavailable');
|
|
424
|
+
if (req.method === 'oomi.models.list') return void this.runtime.models(params.sessionKey).then(reply, failModels);
|
|
425
|
+
if (req.method === 'oomi.models.set') return void this.runtime.setModel(params.sessionKey, params.model).then(reply, failModels);
|
|
379
426
|
if (req.method !== 'chat.send') return fail(`Method ${req.method} is not supported by this agent adapter.`);
|
|
380
427
|
const runId = params.idempotencyKey || req.id || randomUUID();
|
|
381
428
|
reply({ runId, status: 'started' });
|
|
@@ -440,7 +487,7 @@ export function startChannelWorker(state, runtime) {
|
|
|
440
487
|
export function practiceContext(brief) {
|
|
441
488
|
if (!brief || typeof brief !== 'object' || Array.isArray(brief)) return '';
|
|
442
489
|
if (!/^lp_[a-f0-9]{24}$/.test(brief.practiceSessionId || '') || !/^spc_[a-f0-9]{24}$/.test(brief.spaceId || '') ||
|
|
443
|
-
!['es', 'fr', 'de', 'it', 'pt'].includes(brief.targetLanguage) || brief.instructionLanguage !== 'en' ||
|
|
490
|
+
!['es', 'fr', 'de', 'it', 'pt', 'zh-Hans', 'ja', 'ko'].includes(brief.targetLanguage) || brief.instructionLanguage !== 'en' ||
|
|
444
491
|
!['beginner', 'intermediate', 'advanced'].includes(brief.level) || typeof brief.goal !== 'string' || brief.goal.length > 300 ||
|
|
445
492
|
!Number.isInteger(brief.minutesPerDay) || brief.minutesPerDay < 3 || brief.minutesPerDay > 60 ||
|
|
446
493
|
!Array.isArray(brief.phrasesToTry) || brief.phrasesToTry.length > 10 || brief.phrasesToTry.some(p => !p || typeof p.text !== 'string' || p.text.length > 120 || typeof p.translation !== 'string' || p.translation.length > 120)) return '';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `/model` in Talk for OpenClaw personas (docs/specs/chat-models-v1.md). The bridge asks
|
|
2
|
+
// OpenClaw's own gateway methods and answers in oomi's shape: models.list (view "configured":
|
|
3
|
+
// configured models and providers with usable auth) for the choices, chat.history's
|
|
4
|
+
// sessionInfo for the conversation's model, and sessions.patch to switch that session.
|
|
5
|
+
import { ChatModelsError, matchModel, modelChoice, modelsAnswer, openclawEntries } from './chatModels.js';
|
|
6
|
+
|
|
7
|
+
const MODELS = '::oomi-models';
|
|
8
|
+
const SESSION = '::oomi-session';
|
|
9
|
+
const SET = '::oomi-set';
|
|
10
|
+
const STALE_MS = 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function parse(text) { try { const value = JSON.parse(text); return value && typeof value === 'object' ? value : null; } catch { return null; } }
|
|
13
|
+
function request(id, method, params) { return JSON.stringify({ type: 'req', id, method, params }); }
|
|
14
|
+
function answer(id, payload) { return JSON.stringify({ type: 'res', id, ok: true, payload }); }
|
|
15
|
+
function failure(id, code) { const error = new ChatModelsError(code); return JSON.stringify({ type: 'res', id, ok: false, error: { code: error.code, message: error.message } }); }
|
|
16
|
+
|
|
17
|
+
export class OpenclawModelRequests {
|
|
18
|
+
constructor({ now = () => Date.now() } = {}) { this.now = now; this.pending = new Map(); this.entries = []; }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* For an oomi.models.* request: `{ frames }` to send OpenClaw instead, or `{ reply }` to answer
|
|
22
|
+
* the app straight away. Null for every other frame.
|
|
23
|
+
*/
|
|
24
|
+
plan(frameText) {
|
|
25
|
+
const frame = parse(frameText);
|
|
26
|
+
if (frame?.type !== 'req' || (frame.method !== 'oomi.models.list' && frame.method !== 'oomi.models.set')) return null;
|
|
27
|
+
for (const [id, entry] of this.pending) if (this.now() - entry.at > STALE_MS) this.pending.delete(id);
|
|
28
|
+
const id = typeof frame.id === 'string' || typeof frame.id === 'number' ? String(frame.id) : '';
|
|
29
|
+
const sessionKey = typeof frame.params?.sessionKey === 'string' ? frame.params.sessionKey.trim() : '';
|
|
30
|
+
if (!id || !sessionKey) return { reply: failure(id, 'unavailable') };
|
|
31
|
+
if (frame.method === 'oomi.models.list') {
|
|
32
|
+
this.pending.set(id, { kind: 'list', at: this.now() });
|
|
33
|
+
return { frames: [request(id + MODELS, 'models.list', { view: 'configured' }), request(id + SESSION, 'chat.history', { sessionKey, limit: 1 })] };
|
|
34
|
+
}
|
|
35
|
+
const typed = typeof frame.params?.model === 'string' ? frame.params.model.trim() : '';
|
|
36
|
+
if (!typed || typed.length > 300) return { reply: failure(id, 'unknown_model') };
|
|
37
|
+
this.pending.set(id, { kind: 'set', at: this.now() });
|
|
38
|
+
return { frames: [request(id + SET, 'sessions.patch', { key: sessionKey, model: matchModel(typed, this.entries) })] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* For OpenClaw's answer to one of those frames: `{ reply }` once oomi's answer is complete, or
|
|
43
|
+
* `{}` while it waits for the other half. Either way the frame itself goes no further. Null for
|
|
44
|
+
* every other frame.
|
|
45
|
+
*/
|
|
46
|
+
take(frameText) {
|
|
47
|
+
const frame = parse(frameText);
|
|
48
|
+
if (frame?.type !== 'res' || typeof frame.id !== 'string') return null;
|
|
49
|
+
const suffix = [MODELS, SESSION, SET].find((end) => frame.id.endsWith(end));
|
|
50
|
+
const id = suffix ? frame.id.slice(0, -suffix.length) : '';
|
|
51
|
+
const entry = suffix && this.pending.get(id);
|
|
52
|
+
if (!entry) return null;
|
|
53
|
+
if (suffix === SET) {
|
|
54
|
+
this.pending.delete(id);
|
|
55
|
+
if (!frame.ok) return { reply: failure(id, frame.error?.code === 'INVALID_REQUEST' ? 'unknown_model' : 'unavailable') };
|
|
56
|
+
const resolved = frame.payload?.resolved;
|
|
57
|
+
const current = resolved?.modelProvider && resolved?.model ? `${resolved.modelProvider}/${resolved.model}` : '';
|
|
58
|
+
return { reply: answer(id, { scope: 'conversation', current: modelChoice(current, this.entries) }) };
|
|
59
|
+
}
|
|
60
|
+
if (suffix === MODELS) entry.models = frame.ok && Array.isArray(frame.payload?.models) ? openclawEntries(frame.payload.models) : null;
|
|
61
|
+
else {
|
|
62
|
+
const info = frame.ok ? frame.payload?.sessionInfo : null;
|
|
63
|
+
entry.current = info?.modelProvider && info?.model ? `${info.modelProvider}/${info.model}` : '';
|
|
64
|
+
}
|
|
65
|
+
if (entry.models === undefined || entry.current === undefined) return {};
|
|
66
|
+
this.pending.delete(id);
|
|
67
|
+
if (!entry.models) return { reply: failure(id, 'unavailable') };
|
|
68
|
+
this.entries = entry.models;
|
|
69
|
+
return { reply: answer(id, modelsAnswer({ current: entry.current, entries: entry.models })) };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/runtimeFailure.js
CHANGED
|
@@ -15,6 +15,7 @@ export const FAILURE_SUMMARIES = {
|
|
|
15
15
|
model_unavailable: "its AI provider couldn't be reached",
|
|
16
16
|
runtime_timeout: 'it took too long to answer',
|
|
17
17
|
runtime_failed: 'its agent runtime stopped with an error',
|
|
18
|
+
guest_sandbox_unavailable: "its agent runtime can't answer guests with its tools turned off",
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export const FAILURE_HINTS = {
|
|
@@ -23,6 +24,7 @@ export const FAILURE_HINTS = {
|
|
|
23
24
|
model_unavailable: 'Check this computer is online and the provider is up, then try again.',
|
|
24
25
|
runtime_timeout: 'Try again with a shorter request, or check the agent runtime is responsive.',
|
|
25
26
|
runtime_failed: 'Check the agent runtime logs on this computer, then try again.',
|
|
27
|
+
guest_sandbox_unavailable: 'Chat links and agent channels need a Hermes or OpenClaw persona, which can answer guests without running commands or touching files.',
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export function redactDetail(text, limit = 300) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Keeps the persona's oomi-ai current (Justin, 2026-10-25: "make sure the package always requests
|
|
2
|
+
// the agent to stay up to date to avoid security risks"). The running bridge checks npm and, once
|
|
3
|
+
// per new version, asks its agent to update through the agent's own approval flow, or to tell its
|
|
4
|
+
// owner. It never installs anything itself. OOMI_UPDATE_NOTICES=0 turns it off.
|
|
5
|
+
import { readJson, writePrivateJson } from './localAgent.js';
|
|
6
|
+
|
|
7
|
+
export const UPDATE_CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
8
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
9
|
+
|
|
10
|
+
export function newerVersion(current, latest) {
|
|
11
|
+
if (!VERSION.test(String(current)) || !VERSION.test(String(latest))) return false;
|
|
12
|
+
const a = current.split('.').map(Number);
|
|
13
|
+
const b = latest.split('.').map(Number);
|
|
14
|
+
const difference = b.map((value, index) => value - a[index]).find((value) => value !== 0) || 0;
|
|
15
|
+
return difference > 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function updateNotice({ current, latest, connection = '' }) {
|
|
19
|
+
const name = connection ? ` --name ${connection}` : '';
|
|
20
|
+
const conn = connection ? ` --connection ${connection}` : '';
|
|
21
|
+
return `Oomi maintenance notice from the oomi-ai bridge on this computer (not from a person): oomi-ai ${latest} is out; this computer has ${current}. ` +
|
|
22
|
+
'New versions carry security fixes, so keep it current.\n' +
|
|
23
|
+
`1. Run oomi update${conn} for the exact install command, and run that command alone through your normal approval flow.\n` +
|
|
24
|
+
`2. After it succeeds, check the installed version, then run oomi bridge repair${name}.\n` +
|
|
25
|
+
`If you can't (it needs approval you don't have, or you have no terminal here), tell your owner: oomi notify "oomi-ai on this computer needs an update to ${latest}: npm install -g oomi-ai@${latest}"${conn}\n` +
|
|
26
|
+
'Never change security settings or approvals to do this. Reply with one line saying what you did.';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks after `firstDelayMs`, then every `intervalMs`. `deliver` runs one agent turn (the same
|
|
31
|
+
* path as identity and routine notices); `onStatus` records what's available for oomi status.
|
|
32
|
+
*/
|
|
33
|
+
export function startUpdateWatcher({ current, fetchLatest, deliver, sessionKey, receiptPath, connection = '', onStatus = () => {}, intervalMs = UPDATE_CHECK_EVERY_MS, firstDelayMs = 60 * 1000, env = process.env }) {
|
|
34
|
+
if (String(env.OOMI_UPDATE_NOTICES || '').trim() === '0' || !VERSION.test(String(current))) return () => {};
|
|
35
|
+
let stopped = false;
|
|
36
|
+
let timer;
|
|
37
|
+
const check = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const latest = String(await fetchLatest() || '').trim();
|
|
40
|
+
if (stopped || !VERSION.test(latest)) return;
|
|
41
|
+
if (!newerVersion(current, latest)) { onStatus({ updateAvailable: '' }); return; }
|
|
42
|
+
onStatus({ updateAvailable: latest });
|
|
43
|
+
const receipt = readJson(receiptPath, {});
|
|
44
|
+
if (receipt.notifiedVersion === latest) return;
|
|
45
|
+
await deliver({ sessionKey, idempotencyKey: `oomi-update:${latest}`, message: updateNotice({ current, latest, connection }) });
|
|
46
|
+
writePrivateJson(receiptPath, { notifiedVersion: latest, notifiedAt: new Date().toISOString() });
|
|
47
|
+
} catch {
|
|
48
|
+
// A missed check or notice is tried again at the next interval.
|
|
49
|
+
} finally {
|
|
50
|
+
if (!stopped) { timer = setTimeout(check, intervalMs); timer.unref?.(); }
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
timer = setTimeout(check, firstDelayMs);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oomi-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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/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",
|
|
52
|
+
"check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/gatewayRuns.js && node --check lib/processTable.js && node --check lib/localAgent.js && node --check lib/chatModels.js && node --check lib/openclawModels.js && node --check lib/updateWatcher.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
|
"prepublishOnly": "npm run check && node --test test/packageFiles.test.mjs"
|
|
55
55
|
},
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"files": [
|
|
61
61
|
"bin/oomi-ai.js",
|
|
62
62
|
"bin/sessionBridgeState.js",
|
|
63
|
+
"lib/gatewayRuns.js",
|
|
63
64
|
"lib/modelRelayClient.js",
|
|
64
65
|
"lib/openclawPaths.js",
|
|
65
66
|
"lib/openclawProfile.js",
|
|
@@ -74,6 +75,9 @@
|
|
|
74
75
|
"skills/oomi/agent_instructions.md",
|
|
75
76
|
"lib/gatewayProbe.js",
|
|
76
77
|
"lib/localAgent.js",
|
|
78
|
+
"lib/chatModels.js",
|
|
79
|
+
"lib/openclawModels.js",
|
|
80
|
+
"lib/updateWatcher.js",
|
|
77
81
|
"lib/agentIdentity.js",
|
|
78
82
|
"lib/routines.js",
|
|
79
83
|
"lib/spaces.js",
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -519,6 +519,8 @@ Oomi renders simple Markdown in chat: short paragraphs, bullet or numbered lists
|
|
|
519
519
|
|
|
520
520
|
### Language Spaces v1
|
|
521
521
|
|
|
522
|
-
Choose Language in Talk after the owner's instruction; ask only for missing target language (`es`, `fr`, `de`, `it`, `pt`), level, goal and minutes/day (3–60), and confirm English instruction and the IANA timezone. Use `oomi spaces catalog` and build the approved Path / Lesson / Lesson complete / Words / Practice call views from `learning_path`, `exercise`, `step_sequence` variant `lesson`, `recap` variant `language`, `review_deck`, and `practice_feedback`. Write lessons as JSON data, each with `provenance: {kind:"persona", validation:"structural"}`; translations are persona-written and not teacher-checked. A daily objective is a separate opt-in; Space creation schedules nothing.
|
|
522
|
+
Choose Language in Talk after the owner's instruction; ask only for missing target language (`es`, `fr`, `de`, `it`, `pt`, `zh-Hans`, `ja`, `ko`), level, goal and minutes/day (3–60), and confirm English instruction and the IANA timezone. Use `oomi spaces catalog` and build the approved Path / Lesson / Lesson complete / Words / Practice call views from `learning_path`, `exercise`, `step_sequence` variant `lesson`, `recap` variant `language`, `review_deck`, and `practice_feedback`. Write lessons as JSON data, each with `provenance: {kind:"persona", validation:"structural"}`; translations are persona-written and not teacher-checked. A daily objective is a separate opt-in; Space creation schedules nothing.
|
|
523
|
+
|
|
524
|
+
Offer Mandarin (simplified Chinese, `zh-Hans`), Japanese and Korean in Talk like the other languages. At beginner level, use choose, listen-and-choose and arrange; translate answers are in English. Typing in the target language starts at intermediate. List accepted alternatives explicitly, including Japanese kanji and kana spellings where both are natural; never guess alternatives. Put reading aids (pinyin with tone marks, romaji or Revised Romanization) in `explanation`, optionally after a word in an option, like "你好 (nǐ hǎo)". Never put romanization in an accepted text unless the exercise asks for romanization.
|
|
523
525
|
|
|
524
526
|
Read before writing; send `space.v4`, revision, stable idempotency key and an owner `sourceMessageId`. The CLI advertises `cooking.v1, language.v1`. `oomi spaces language-action SPACE --component COMPONENT --file action.json` performs permitted actions. Never answer or skip exercises for the owner. After an ordinary practice call ends, use that handoff's `practiceSessionId` with `action:"save_practice_feedback"`, at most five `phrasesToTry` (`text,translation`) and five `corrections` (`original,suggested,reason`), each field at most 300 characters. Feedback is your suggestions, not mastery scoring. A call is eligible only after its practice brief was accepted. The feedback event stays pending until the runtime confirms startup; confirmed non-starts retry, and an uncertain start gets one recovery attempt with the same dedupe key before expiring after 24 hours. Read it with `oomi spaces practice-feedback SPACE --component COMPONENT`; paginate cards with `oomi spaces reviews SPACE --component COMPONENT [--cursor CURSOR]`. Never fabricate a call, request audio from client-written text, or start a microphone. Calls are saved in Talk.
|