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 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
- * Tells Oomi which models the runtime offers, so the persona's page can offer a faster one for
1151
- * calls (Voice::CallModel). False when it didn't arrive, so the next turn reports again.
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 ||= { call: {} };
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
- params.role = typeof rawParams.role === 'string' && rawParams.role.trim() ? rawParams.role.trim() : 'operator';
1501
-
1502
- const existingScopes = Array.isArray(rawParams.scopes)
1503
- ? rawParams.scopes.filter((value) => typeof value === 'string' && value.trim())
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: existingScopes,
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
- function annotateConnectResponseForOomi(frameText, { runtime = 'openclaw', version = '' } = {}) {
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' ? [] : [...OOMI_BRIDGE_CAPABILITIES];
1769
- return JSON.stringify({ ...frame, payload: { ...payload, oomi: { ...existing, bridgeVersion: version, capabilities } } });
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 = new Set();
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.add(gatewayPayload.payload.runId);
3443
+ activeGatewayRuns.started(sessionId, gatewayPayload.payload.runId);
3464
3444
  }
3465
3445
  if (gatewayPayload?.event === 'chat' && ['final', 'error', 'aborted'].includes(gatewayPayload.payload?.state)) {
3466
- activeGatewayRuns.delete(gatewayPayload.payload.runId);
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
- const frame = typeof payload.frame === 'string' ? payload.frame : '';
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;
@@ -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 text = typeof reply?.text === 'string' ? reply.text.trim() : '';
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' } });
@@ -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
- if (!stopped && identity?.revision > 0 && identity.syncStatus === 'pending' && identity.source !== 'agent') {
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
+ }
@@ -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
- turnSent = true;
55
- socket.send(JSON.stringify({ type: 'req', id: runId, method: 'chat.send', params: {
56
- sessionKey: turn?.sessionKey || `agent:main:oomi:verify:${runId}`, idempotencyKey: runId,
57
- message: turn?.message || 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
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, 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).
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.newSessionModel = '';
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
- if (!models || typeof models !== 'object') return;
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.newSessionModel = current;
93
+ if (created) this.defaultModel = current;
97
94
  }
98
- if (Array.isArray(models.availableModels)) {
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 applyCallModel(client, sessionId, request);
411
+ let chatModel = await restoreChatModel(client, sessionId, request);
403
412
  let text;
404
413
  try {
405
- text = await client.prompt(sessionId, promptBlocks(request, created ? seed() : ''), { signal, timeoutMs, onText });
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 chat model.
410
- chatModel = await applyCallModel(client, sessionId, { ...request, chatModel: null });
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
- * 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.
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 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;
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
- /** 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;
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
- /** "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;
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, hermesAcpModelReport } from './hermesAcp.js';
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
- 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: text => { confirmStarted(); onText?.(text); }, onStarted: confirmStarted });
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
- state.runtimeSessionId = result.sessionId;
275
- if (result.transport) state.runtimeTransport = result.transport;
276
- // While the session is on a call's model, the model to go back to (kept here, so a
277
- // restart still restores it before the next chat turn).
278
- if ('chatModel' in result) {
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
- /** Gets Hermes ready before a message arrives: at bridge start and when a call opens. */
310
- warm() {
311
- if (this.config.runtime !== 'hermes' || !usesHermesAcp(this.config)) return Promise.resolve();
312
- return warmHermesAcp(this.config, this.recentAcpSessions(2)).then(() => this.reportModels(), () => {});
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
- /** Tells onModels what the runtime offers for calls, once Hermes has said and whenever it changes. */
315
- reportModels(chatModel = '') {
316
- if (!this.onModels || this.config.runtime !== 'hermes') return;
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 conversation on a call's model knows what the persona chats with. */
326
- rememberedChatModel() {
327
- const directory = path.join(this.directory, 'conversations');
328
- let files;
329
- try { files = fs.readdirSync(directory).filter((name) => name.endsWith('.json')); } catch { return ''; }
330
- for (const name of files) {
331
- const state = readJson(path.join(directory, name), {});
332
- if (typeof state.runtimeChatModel === 'string' && state.runtimeChatModel) return state.runtimeChatModel;
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
- return '';
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: 'local_agent_error', message } });
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
+ }
@@ -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.12.1",
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",
@@ -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.