oomi-ai 0.8.5 → 0.9.1
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/README.md +1 -1
- package/bin/oomi-ai.js +9 -2
- package/lib/hermesAcp.js +372 -0
- package/lib/localAgent.js +52 -6
- package/package.json +3 -2
- package/skills/oomi/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
|
109
109
|
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, Hermes
|
|
112
|
+
Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, an Oomi-owned conversation (no `--resume`) runs on a warm `hermes acp` process with oomi-ai 0.9.0+: replies stream as they are written and stop when the person interrupts. From 0.9.1 the bridge starts Hermes and reopens recent conversations ahead of time (at bridge start and when a call opens), so the first message doesn't wait for Hermes to start. Hermes permission requests are denied, as `chat --quiet` denies dangerous commands; ordinary file edits run as they do in quiet mode. A conversation moving from the CLI starts a new Hermes session that receives the recent Oomi history. `--resume` conversations, Hermes without ACP, and `--hermes-transport cli` use `chat --quiet --query-file -` and resume the exact returned `session_id` on subsequent turns.
|
|
113
113
|
|
|
114
114
|
Shared desktop conversations require Hermes's loopback WebSocket gateway, active-session discovery, and durable prompt/reply row IDs. Busy conversations ask you to retry after the current task. If a simultaneous desktop send queues your message without a reply receipt, or the connection drops after sending, check the same Hermes chat before resending. Oomi cannot safely repeat an uncertain action. Human approvals stay in the existing Hermes desktop UI. Shared photo submission is not yet supported; use Hermes desktop for that photo. Older or terminal-only owners without a shared gateway receive an actionable error, never a competing process. The shared owner keeps its existing environment, so use the explicit Oomi `--connection NAME` for account commands.
|
|
115
115
|
|
package/bin/oomi-ai.js
CHANGED
|
@@ -210,6 +210,7 @@ Commands:
|
|
|
210
210
|
connect --runtime openclaw|hermes|grokbot|command [--name CONNECTION] [--agent-name "Your name"] [--app-url URL]
|
|
211
211
|
Verify the selected local agent, pair once, start its bridge, and return an Auth Link.
|
|
212
212
|
Hermes: --profile PROFILE and --resume SESSION select the intended agent/session.
|
|
213
|
+
Hermes: --hermes-transport auto|acp|cli (default auto streams replies over ACP when Hermes supports it).
|
|
213
214
|
Other agents: --command '["/absolute/path/to/adapter"]' (JSON stdin / JSON stdout).
|
|
214
215
|
--cwd PATH selects the agent working directory; --name isolates multiple agents.
|
|
215
216
|
Installs automatic startup on macOS/Linux. --no-service opts into external supervision.
|
|
@@ -2538,6 +2539,7 @@ async function startOpenclawBridge(flags) {
|
|
|
2538
2539
|
const localRuntime = bridgeState.localAgent
|
|
2539
2540
|
? new LocalAgentRuntime({ ...bridgeState.localAgent, connection: bridgeConnectionName }, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
|
|
2540
2541
|
: null;
|
|
2542
|
+
localRuntime?.warm();
|
|
2541
2543
|
const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
|
|
2542
2544
|
if (!localRuntime && !gateway.token && !gateway.password) {
|
|
2543
2545
|
throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
|
|
@@ -3599,6 +3601,8 @@ async function startOpenclawBridge(flags) {
|
|
|
3599
3601
|
const sessionId = String(payload.sessionId || '').trim();
|
|
3600
3602
|
if (!sessionId) return;
|
|
3601
3603
|
bridgeDebugLog(`[bridge] client.open ${sessionId}`);
|
|
3604
|
+
// A call is starting: make sure the agent is awake before the caller's first words.
|
|
3605
|
+
if (classifyBridgeSessionScope(sessionId) === 'voice') localRuntime?.warm();
|
|
3602
3606
|
getOrCreateGatewaySession(sessionId);
|
|
3603
3607
|
return;
|
|
3604
3608
|
}
|
|
@@ -3826,7 +3830,7 @@ async function startOpenclawBridge(flags) {
|
|
|
3826
3830
|
stopChannelWorker();
|
|
3827
3831
|
stopIdentityWorker();
|
|
3828
3832
|
stopRoutineWorker();
|
|
3829
|
-
if (localRuntime) await localRuntime.queue;
|
|
3833
|
+
if (localRuntime) { await localRuntime.queue; localRuntime.close(); }
|
|
3830
3834
|
if (reconnectState.timer) {
|
|
3831
3835
|
clearTimeout(reconnectState.timer);
|
|
3832
3836
|
reconnectState.timer = null;
|
|
@@ -4472,9 +4476,12 @@ async function connectLocalAgent(flags) {
|
|
|
4472
4476
|
profile: flags.profile ?? saved?.profile,
|
|
4473
4477
|
'hermes-home': saved?.hermesHome,
|
|
4474
4478
|
resume: flags.resume ?? saved?.resume,
|
|
4479
|
+
'hermes-transport': flags['hermes-transport'] ?? saved?.hermesTransport,
|
|
4475
4480
|
cwd: flags.cwd ?? saved?.cwd,
|
|
4476
4481
|
});
|
|
4477
|
-
|
|
4482
|
+
// How Hermes is reached (ACP or the quiet CLI) may change; the agent itself may not.
|
|
4483
|
+
const identity = ({ hermesTransport, ...rest } = {}) => JSON.stringify(rest);
|
|
4484
|
+
if (saved && identity(saved) !== identity(state.localAgent)) throw new Error('This name already identifies a different agent configuration. Use a new --name to change its command, profile, session, or working directory.');
|
|
4478
4485
|
}
|
|
4479
4486
|
if (!previous.verifiedAt || isTruthyFlag(flags.verify)) {
|
|
4480
4487
|
console.log(`Verifying ${runtime}${state.localAgent?.profile ? ` profile ${state.localAgent.profile}` : ''} with a local test message...`);
|
package/lib/hermesAcp.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
// Hermes over the Agent Client Protocol (`hermes acp`): one warm process per Hermes
|
|
6
|
+
// profile, kept between turns, whose replies stream as they are written and can be
|
|
7
|
+
// cancelled mid-turn. JSON-RPC 2.0, one message per line, over stdio.
|
|
8
|
+
//
|
|
9
|
+
// Approvals stay at least as strict as `hermes chat --quiet`, which denies dangerous
|
|
10
|
+
// commands without asking: Oomi denies every permission request Hermes sends. Sessions
|
|
11
|
+
// run in Hermes's "Don't ask" edit mode, so ordinary file edits work as they do in quiet
|
|
12
|
+
// mode and only sensitive paths (.git, .ssh, .env) reach that denial.
|
|
13
|
+
|
|
14
|
+
const PROTOCOL_VERSION = 1;
|
|
15
|
+
const START_TIMEOUT_MS = 20000;
|
|
16
|
+
const CONTROL_TIMEOUT_MS = 30000;
|
|
17
|
+
const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000;
|
|
18
|
+
const TEXT_INTERVAL_MS = 80;
|
|
19
|
+
const MAX_REPLY_CHARS = 4 * 1024 * 1024;
|
|
20
|
+
const EDIT_MODE = 'dont_ask';
|
|
21
|
+
const IMAGE_TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
22
|
+
const SEED_MESSAGES = 12;
|
|
23
|
+
const SEED_MESSAGE_CHARS = 800;
|
|
24
|
+
const SEED_TOTAL_CHARS = 6000;
|
|
25
|
+
|
|
26
|
+
// Only fixed messages cross the bridge; Hermes diagnostics may hold credentials or tool output.
|
|
27
|
+
export class HermesAcpError extends Error {
|
|
28
|
+
constructor(code, message, { notStarted = false, runtimeReady = true } = {}) {
|
|
29
|
+
super(message); this.code = code; this.notStarted = notStarted; this.runtimeReady = runtimeReady;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const unavailable = () => new HermesAcpError('hermes_acp_unavailable', 'Hermes could not start its streaming connection.', { notStarted: true, runtimeReady: false });
|
|
33
|
+
const exited = () => new HermesAcpError('hermes_acp_exited', 'Hermes stopped while answering. Check Hermes on that computer before sending this message again.', { runtimeReady: false });
|
|
34
|
+
|
|
35
|
+
const clients = new Map();
|
|
36
|
+
const unavailableUntil = new Map();
|
|
37
|
+
|
|
38
|
+
function clientKey(config) {
|
|
39
|
+
return JSON.stringify([config.command, config.hermesHome || '', config.profile || '', config.cwd || '', config.connection || '']);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** False while a recent start attempt failed, so a broken install doesn't delay every turn. */
|
|
43
|
+
export function hermesAcpAvailable(config) {
|
|
44
|
+
return (unavailableUntil.get(clientKey(config)) || 0) <= Date.now();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drop the warm process, for example after another Hermes client changed the conversation. */
|
|
48
|
+
export function resetHermesAcp(config) {
|
|
49
|
+
const key = clientKey(config);
|
|
50
|
+
clients.get(key)?.close();
|
|
51
|
+
clients.delete(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function closeHermesAcpClients() {
|
|
55
|
+
for (const client of clients.values()) client.close();
|
|
56
|
+
clients.clear();
|
|
57
|
+
unavailableUntil.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hermesAcpClient(config) {
|
|
61
|
+
const key = clientKey(config);
|
|
62
|
+
let client = clients.get(key);
|
|
63
|
+
if (!client || client.closed) {
|
|
64
|
+
client = new HermesAcpClient(config, () => unavailableUntil.set(key, Date.now() + UNAVAILABLE_BACKOFF_MS));
|
|
65
|
+
clients.set(key, client);
|
|
66
|
+
}
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class HermesAcpClient {
|
|
71
|
+
constructor(config, onUnavailable) {
|
|
72
|
+
this.config = config;
|
|
73
|
+
this.onUnavailable = onUnavailable;
|
|
74
|
+
this.nextId = 0;
|
|
75
|
+
this.pending = new Map();
|
|
76
|
+
this.turns = new Map();
|
|
77
|
+
this.loaded = new Set();
|
|
78
|
+
this.replayed = new Map();
|
|
79
|
+
this.closed = false;
|
|
80
|
+
this.ready = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
start() {
|
|
84
|
+
if (this.ready) return this.ready;
|
|
85
|
+
this.ready = this.spawnAndInitialize().catch((err) => {
|
|
86
|
+
this.onUnavailable();
|
|
87
|
+
this.close();
|
|
88
|
+
throw err instanceof HermesAcpError ? err : unavailable();
|
|
89
|
+
});
|
|
90
|
+
return this.ready;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async spawnAndInitialize() {
|
|
94
|
+
const { config } = this;
|
|
95
|
+
const args = config.command.slice(1);
|
|
96
|
+
if (config.profile) args.push('--profile', config.profile);
|
|
97
|
+
args.push('acp');
|
|
98
|
+
// The agent's own `oomi` commands (notify, routines, spaces) use this connection by default.
|
|
99
|
+
const env = { ...process.env, ...(config.hermesHome ? { HERMES_HOME: config.hermesHome } : {}), ...(config.connection ? { OOMI_CONNECTION: config.connection } : {}) };
|
|
100
|
+
const child = spawn(config.command[0], args, { env, cwd: config.cwd, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
101
|
+
this.child = child;
|
|
102
|
+
let buffered = '';
|
|
103
|
+
child.stdout.setEncoding('utf8');
|
|
104
|
+
child.stdout.on('data', (data) => {
|
|
105
|
+
buffered += data;
|
|
106
|
+
let newline;
|
|
107
|
+
while ((newline = buffered.indexOf('\n')) >= 0) {
|
|
108
|
+
const line = buffered.slice(0, newline).trim();
|
|
109
|
+
buffered = buffered.slice(newline + 1);
|
|
110
|
+
if (!line) continue;
|
|
111
|
+
let message;
|
|
112
|
+
try { message = JSON.parse(line); } catch { continue; } // Stray output is not protocol.
|
|
113
|
+
this.handle(message);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// Diagnostics may contain credentials or tool output; never forward them to Oomi.
|
|
117
|
+
child.stderr.resume();
|
|
118
|
+
child.stdin.on('error', () => {});
|
|
119
|
+
const spawnFailed = new Promise((_, reject) => child.once('error', () => reject(unavailable())));
|
|
120
|
+
spawnFailed.catch(() => {});
|
|
121
|
+
child.once('exit', () => this.exited());
|
|
122
|
+
|
|
123
|
+
const initialized = await Promise.race([
|
|
124
|
+
this.request('initialize', {
|
|
125
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
126
|
+
clientCapabilities: {},
|
|
127
|
+
clientInfo: { name: 'oomi-ai', title: 'Oomi', version: packageVersion() },
|
|
128
|
+
}, START_TIMEOUT_MS),
|
|
129
|
+
spawnFailed,
|
|
130
|
+
]).catch(() => { throw unavailable(); });
|
|
131
|
+
if (initialized?.protocolVersion !== PROTOCOL_VERSION) throw unavailable();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
request(method, params, timeoutMs = CONTROL_TIMEOUT_MS) {
|
|
135
|
+
if (this.closed || !this.child) return Promise.reject(exited());
|
|
136
|
+
const id = ++this.nextId;
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const timer = timeoutMs ? setTimeout(() => { this.pending.delete(id); reject(new HermesAcpError('hermes_acp_timeout', 'Hermes did not respond in time.')); }, timeoutMs) : null;
|
|
139
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
140
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
notify(method, params) {
|
|
145
|
+
if (!this.closed && this.child) this.write({ jsonrpc: '2.0', method, params });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
write(message) {
|
|
149
|
+
try { this.child.stdin.write(JSON.stringify(message) + '\n'); } catch { /* The exit handler reports it. */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
handle(message) {
|
|
153
|
+
if (message.method && message.id !== undefined) return this.answer(message);
|
|
154
|
+
if (message.method === 'session/update') return this.update(message.params);
|
|
155
|
+
if (message.id === undefined || !this.pending.has(message.id)) return;
|
|
156
|
+
const pending = this.pending.get(message.id);
|
|
157
|
+
this.pending.delete(message.id);
|
|
158
|
+
clearTimeout(pending.timer);
|
|
159
|
+
if (message.error) pending.reject(Object.assign(new HermesAcpError('hermes_acp_rpc', 'Hermes rejected the request.'), { rpcCode: message.error.code }));
|
|
160
|
+
else pending.resolve(message.result ?? null);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Requests from Hermes. Oomi offers no file or terminal access of its own, and denies
|
|
164
|
+
// every permission request (see the note at the top).
|
|
165
|
+
answer(message) {
|
|
166
|
+
if (message.method === 'session/request_permission') {
|
|
167
|
+
const options = Array.isArray(message.params?.options) ? message.params.options : [];
|
|
168
|
+
const deny = options.find((option) => option?.kind === 'reject_once') || options.find((option) => option?.kind === 'reject_always');
|
|
169
|
+
const outcome = deny ? { outcome: 'selected', optionId: deny.optionId } : { outcome: 'cancelled' };
|
|
170
|
+
this.write({ jsonrpc: '2.0', id: message.id, result: { outcome } });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.write({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Not supported by Oomi.' } });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
update(params) {
|
|
177
|
+
const sessionId = params?.sessionId;
|
|
178
|
+
if (!sessionId) return;
|
|
179
|
+
if (this.replayed.has(sessionId)) this.replayed.set(sessionId, this.replayed.get(sessionId) + 1);
|
|
180
|
+
const turn = this.turns.get(sessionId);
|
|
181
|
+
const update = params.update;
|
|
182
|
+
if (!turn || update?.sessionUpdate !== 'agent_message_chunk' || update.content?.type !== 'text') return;
|
|
183
|
+
turn.onChunk(String(update.content.text || ''), update.messageId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
exited() {
|
|
187
|
+
const error = exited();
|
|
188
|
+
this.closed = true;
|
|
189
|
+
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
|
|
190
|
+
this.pending.clear();
|
|
191
|
+
for (const turn of this.turns.values()) turn.fail(error);
|
|
192
|
+
this.turns.clear();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The ACP session for a conversation in this process: the one it already has, a stored one
|
|
197
|
+
* reloaded from Hermes's history, or a new one. `created` says whether history was lost.
|
|
198
|
+
*/
|
|
199
|
+
async openSession(sessionId, { create = true } = {}) {
|
|
200
|
+
const cwd = this.config.cwd;
|
|
201
|
+
if (sessionId && this.loaded.has(sessionId)) return { sessionId, created: false };
|
|
202
|
+
if (sessionId) {
|
|
203
|
+
// Hermes answers a load for an unknown session with an empty success, so a session
|
|
204
|
+
// counts as found only when its history is replayed.
|
|
205
|
+
this.replayed.set(sessionId, 0);
|
|
206
|
+
try {
|
|
207
|
+
await this.request('session/load', { sessionId, cwd, mcpServers: [] });
|
|
208
|
+
if (this.replayed.get(sessionId) > 0) {
|
|
209
|
+
await this.prepare(sessionId);
|
|
210
|
+
return { sessionId, created: false };
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (err.code === 'hermes_acp_exited') throw err;
|
|
214
|
+
} finally { this.replayed.delete(sessionId); }
|
|
215
|
+
}
|
|
216
|
+
if (!create) return null;
|
|
217
|
+
const created = await this.request('session/new', { cwd, mcpServers: [] });
|
|
218
|
+
if (typeof created?.sessionId !== 'string' || !created.sessionId) throw unavailable();
|
|
219
|
+
await this.prepare(created.sessionId);
|
|
220
|
+
return { sessionId: created.sessionId, created: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async prepare(sessionId) {
|
|
224
|
+
// Without this mode Hermes asks before every file edit, which Oomi would deny.
|
|
225
|
+
await this.request('session/set_mode', { sessionId, modeId: EDIT_MODE }).catch(() => {});
|
|
226
|
+
this.loaded.add(sessionId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
prompt(sessionId, prompt, { signal, timeoutMs = 180000, onText } = {}) {
|
|
230
|
+
if (this.turns.has(sessionId)) return Promise.reject(new HermesAcpError('hermes_session_busy', 'Hermes is still answering in this conversation.', { notStarted: true }));
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
let text = '';
|
|
233
|
+
let sent = '';
|
|
234
|
+
let messageId;
|
|
235
|
+
let textTimer = null;
|
|
236
|
+
let settled = false;
|
|
237
|
+
const flush = () => {
|
|
238
|
+
textTimer = null;
|
|
239
|
+
if (text !== sent) { sent = text; onText?.(text); }
|
|
240
|
+
};
|
|
241
|
+
const finish = (callback, value) => {
|
|
242
|
+
if (settled) return;
|
|
243
|
+
settled = true;
|
|
244
|
+
clearTimeout(timer); clearTimeout(textTimer);
|
|
245
|
+
signal?.removeEventListener('abort', abort);
|
|
246
|
+
this.turns.delete(sessionId);
|
|
247
|
+
callback(value);
|
|
248
|
+
};
|
|
249
|
+
const cancel = (error) => {
|
|
250
|
+
this.notify('session/cancel', { sessionId });
|
|
251
|
+
finish(reject, error);
|
|
252
|
+
};
|
|
253
|
+
const abort = () => cancel(new HermesAcpError('cancelled', 'Agent turn cancelled.'));
|
|
254
|
+
const timer = setTimeout(() => cancel(new HermesAcpError('timeout', 'Agent turn timed out.')), timeoutMs);
|
|
255
|
+
this.turns.set(sessionId, {
|
|
256
|
+
onChunk: (chunk, id) => {
|
|
257
|
+
if (settled || !chunk) return;
|
|
258
|
+
// Words before and after a tool call are separate messages; keep them apart.
|
|
259
|
+
if (id && messageId && id !== messageId && text && !/\s$/.test(text)) text += '\n\n';
|
|
260
|
+
if (id) messageId = id;
|
|
261
|
+
if (text.length + chunk.length > MAX_REPLY_CHARS) return cancel(new HermesAcpError('too_long', 'Agent output exceeded 4 MiB.'));
|
|
262
|
+
text += chunk;
|
|
263
|
+
// Send the first words right away, then at most every TEXT_INTERVAL_MS.
|
|
264
|
+
if (!sent) flush();
|
|
265
|
+
else textTimer ??= setTimeout(flush, TEXT_INTERVAL_MS);
|
|
266
|
+
},
|
|
267
|
+
fail: (error) => finish(reject, error),
|
|
268
|
+
});
|
|
269
|
+
if (signal?.aborted) return abort();
|
|
270
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
271
|
+
this.request('session/prompt', { sessionId, prompt }, 0).then((result) => {
|
|
272
|
+
if (settled) return;
|
|
273
|
+
const reply = text.trim();
|
|
274
|
+
if (result?.stopReason === 'cancelled') return finish(reject, new HermesAcpError('cancelled', 'Agent turn cancelled.'));
|
|
275
|
+
if (result?.stopReason === 'refusal' && !reply) {
|
|
276
|
+
this.loaded.delete(sessionId);
|
|
277
|
+
return finish(reject, new HermesAcpError('hermes_session_unknown', 'Hermes no longer has this conversation.', { notStarted: true }));
|
|
278
|
+
}
|
|
279
|
+
if (!reply) return finish(reject, new HermesAcpError('empty_reply', 'Agent did not return a successful final reply.'));
|
|
280
|
+
clearTimeout(textTimer);
|
|
281
|
+
flush();
|
|
282
|
+
finish(resolve, reply);
|
|
283
|
+
}, (err) => finish(reject, err));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
close() {
|
|
288
|
+
if (this.closed && !this.child) return;
|
|
289
|
+
this.closed = true;
|
|
290
|
+
const child = this.child;
|
|
291
|
+
this.child = null;
|
|
292
|
+
if (!child || child.exitCode !== null) return;
|
|
293
|
+
const terminate = (signal) => {
|
|
294
|
+
try {
|
|
295
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
296
|
+
else child.kill(signal);
|
|
297
|
+
} catch { /* Already exited. */ }
|
|
298
|
+
};
|
|
299
|
+
try { child.stdin.end(); } catch { /* Closed. */ }
|
|
300
|
+
terminate('SIGTERM');
|
|
301
|
+
setTimeout(() => terminate('SIGKILL'), 1000).unref();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let cachedVersion;
|
|
306
|
+
function packageVersion() {
|
|
307
|
+
if (cachedVersion) return cachedVersion;
|
|
308
|
+
try { cachedVersion = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { cachedVersion = '0.0.0'; }
|
|
309
|
+
return cachedVersion;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function textOf(content) {
|
|
313
|
+
if (typeof content === 'string') return content;
|
|
314
|
+
if (!Array.isArray(content)) return '';
|
|
315
|
+
return content.map((part) => (part?.type === 'text' ? part.text : part?.type === 'image' ? '[photo]' : '')).filter(Boolean).join(' ');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A conversation that moves to a new Hermes session (from the quiet CLI, or after Hermes lost
|
|
319
|
+
// it) keeps its thread: the first prompt carries the recent Oomi history.
|
|
320
|
+
export function continuityPreamble(history) {
|
|
321
|
+
const recent = (Array.isArray(history) ? history : []).slice(-SEED_MESSAGES);
|
|
322
|
+
const lines = [];
|
|
323
|
+
let total = 0;
|
|
324
|
+
for (const message of recent) {
|
|
325
|
+
const words = textOf(message?.content).trim();
|
|
326
|
+
if (!words || !['user', 'assistant'].includes(message?.role)) continue;
|
|
327
|
+
const line = `${message.role === 'user' ? 'User' : 'You'}: ${words.length > SEED_MESSAGE_CHARS ? `${words.slice(0, SEED_MESSAGE_CHARS)}…` : words}`;
|
|
328
|
+
total += line.length;
|
|
329
|
+
if (total > SEED_TOTAL_CHARS) break;
|
|
330
|
+
lines.push(line);
|
|
331
|
+
}
|
|
332
|
+
if (!lines.length) return '';
|
|
333
|
+
return `For continuity, here is the recent part of this Oomi conversation:\n${lines.join('\n')}\n---\n`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function promptBlocks(request, seed) {
|
|
337
|
+
const blocks = [{ type: 'text', text: `${seed}${request.message}` }];
|
|
338
|
+
const image = request.imagePaths?.[0];
|
|
339
|
+
if (image) {
|
|
340
|
+
const mimeType = IMAGE_TYPES[path.extname(image).toLowerCase()];
|
|
341
|
+
if (mimeType) blocks.push({ type: 'image', mimeType, data: fs.readFileSync(image).toString('base64') });
|
|
342
|
+
}
|
|
343
|
+
return blocks;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Starts Hermes ahead of the first message and reopens recent conversations, so a call's first
|
|
348
|
+
* turn doesn't wait for Hermes to start (about 3.5 s on the maintainer's Mac).
|
|
349
|
+
*/
|
|
350
|
+
export async function warmHermesAcp(config, sessionIds = []) {
|
|
351
|
+
const client = hermesAcpClient(config);
|
|
352
|
+
await client.start();
|
|
353
|
+
for (const sessionId of sessionIds) await client.openSession(sessionId, { create: false }).catch(() => null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** One Hermes turn over ACP. Returns the reply and the ACP session that holds the conversation. */
|
|
357
|
+
export async function runHermesAcpTurn(config, request, { signal, timeoutMs = 180000, onText } = {}) {
|
|
358
|
+
const client = hermesAcpClient(config);
|
|
359
|
+
await client.start();
|
|
360
|
+
const continuing = request.runtimeTransport === 'acp' ? request.runtimeSessionId : '';
|
|
361
|
+
let { sessionId, created } = await client.openSession(continuing);
|
|
362
|
+
const seed = () => continuityPreamble(request.history);
|
|
363
|
+
let text;
|
|
364
|
+
try {
|
|
365
|
+
text = await client.prompt(sessionId, promptBlocks(request, created ? seed() : ''), { signal, timeoutMs, onText });
|
|
366
|
+
} catch (err) {
|
|
367
|
+
if (err.code !== 'hermes_session_unknown' || created) throw err;
|
|
368
|
+
({ sessionId } = await client.openSession(''));
|
|
369
|
+
text = await client.prompt(sessionId, promptBlocks(request, seed()), { signal, timeoutMs, onText });
|
|
370
|
+
}
|
|
371
|
+
return { text, sessionId, transport: 'acp' };
|
|
372
|
+
}
|
package/lib/localAgent.js
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
7
|
import { runSharedHermesTurn, hermesRefusal } from './hermesGateway.js';
|
|
8
|
+
import { runHermesAcpTurn, hermesAcpAvailable, resetHermesAcp, warmHermesAcp } from './hermesAcp.js';
|
|
8
9
|
import { grokbotConfig, runGrokbotTurn, completedGrokbotTurn } from './grokbotRuntime.js';
|
|
9
10
|
|
|
10
11
|
const IMAGE_EXTENSIONS = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
|
|
@@ -81,6 +82,9 @@ export function localAgentConfig(flags) {
|
|
|
81
82
|
if (!Array.isArray(command) || !command.length || command.some((arg) => typeof arg !== 'string') || !command[0]) throw new Error('--command must contain an executable and optional arguments.');
|
|
82
83
|
}
|
|
83
84
|
if (flags.resume === 'latest') throw new Error('Use an exact Hermes session ID, never latest.');
|
|
85
|
+
// Unset means auto: ACP when Hermes supports it, otherwise the quiet CLI.
|
|
86
|
+
const hermesTransport = flags['hermes-transport'] ? String(flags['hermes-transport']) : undefined;
|
|
87
|
+
if (hermesTransport && !['auto', 'acp', 'cli'].includes(hermesTransport)) throw new Error('--hermes-transport must be auto, acp, or cli.');
|
|
84
88
|
let profile = flags.profile || '';
|
|
85
89
|
let hermesHome;
|
|
86
90
|
if (runtime === 'hermes') {
|
|
@@ -97,7 +101,15 @@ export function localAgentConfig(flags) {
|
|
|
97
101
|
profile ||= 'default';
|
|
98
102
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) throw new Error('Specify a valid Hermes --profile.');
|
|
99
103
|
}
|
|
100
|
-
return { runtime, command, profile, hermesHome, resume: flags.resume || '', cwd: path.resolve(flags.cwd || process.cwd()) };
|
|
104
|
+
return { runtime, command, profile, hermesHome, resume: flags.resume || '', ...(runtime === 'hermes' && hermesTransport ? { hermesTransport } : {}), cwd: path.resolve(flags.cwd || process.cwd()) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Hermes streams over ACP for conversations Oomi owns. A conversation pinned to an exact
|
|
108
|
+
// existing Hermes session (--resume) stays on the quiet CLI, because ACP can only reopen
|
|
109
|
+
// sessions it created.
|
|
110
|
+
function usesHermesAcp(config) {
|
|
111
|
+
if (config.hermesTransport === 'cli' || config.resume) return false;
|
|
112
|
+
return config.hermesTransport === 'acp' || hermesAcpAvailable(config);
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
// No shell interpolation. Message bodies travel over stdin, never as executable code.
|
|
@@ -105,7 +117,18 @@ export async function runLocalAgent(config, request, options = {}) {
|
|
|
105
117
|
if (config.runtime === 'grokbot') return runGrokbotTurn(config, request, options);
|
|
106
118
|
if (config.runtime === 'hermes') {
|
|
107
119
|
const shared = await runSharedHermesTurn(config, request, options);
|
|
108
|
-
if (shared)
|
|
120
|
+
if (shared) {
|
|
121
|
+
// Hermes desktop answered in this conversation; the warm ACP copy is now behind.
|
|
122
|
+
if (request.runtimeTransport === 'acp') resetHermesAcp(config);
|
|
123
|
+
return { ...shared, ...(request.runtimeTransport ? { transport: request.runtimeTransport } : {}) };
|
|
124
|
+
}
|
|
125
|
+
if (usesHermesAcp(config)) {
|
|
126
|
+
try { return await runHermesAcpTurn(config, request, options); }
|
|
127
|
+
catch (err) {
|
|
128
|
+
// Only a Hermes that can't start ACP falls back, and only before anything was sent.
|
|
129
|
+
if (err.code !== 'hermes_acp_unavailable' || config.hermesTransport === 'acp') throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
109
132
|
}
|
|
110
133
|
try { return await runAgentProcess(config, request, options); }
|
|
111
134
|
catch (err) {
|
|
@@ -120,6 +143,7 @@ export async function runLocalAgent(config, request, options = {}) {
|
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
|
|
146
|
+
if (config.runtime === 'hermes') request = { ...request, transport: 'cli' };
|
|
123
147
|
return new Promise((resolve, reject) => {
|
|
124
148
|
const args = config.command.slice(1);
|
|
125
149
|
if (config.runtime === 'hermes') {
|
|
@@ -176,7 +200,7 @@ function runAgentProcess(config, request, { signal, timeoutMs = 180000 } = {}) {
|
|
|
176
200
|
? { text: output, sessionId: diagnostics.match(/(?:^|\n)session_id:\s*([A-Za-z0-9_-]+)\s*(?:\n|$)/)?.[1] }
|
|
177
201
|
: JSON.parse(output);
|
|
178
202
|
if (!result || (config.runtime === 'hermes' && !result.sessionId) || typeof result.text !== 'string' || !result.text.trim()) throw new Error();
|
|
179
|
-
resolve({ text: result.text.trim(), sessionId: result.session_id || result.sessionId || request.runtimeSessionId || '' });
|
|
203
|
+
resolve({ text: result.text.trim(), sessionId: result.session_id || result.sessionId || request.runtimeSessionId || '', ...(request.transport ? { transport: request.transport } : {}) });
|
|
180
204
|
} catch { reject(new Error('Agent did not return a successful final reply. Hermes must emit its quiet-mode session_id; command adapters must return {"text":"..."}.')); }
|
|
181
205
|
});
|
|
182
206
|
child.stdin.end(config.runtime === 'hermes' ? request.message : JSON.stringify(request) + '\n');
|
|
@@ -194,7 +218,7 @@ export class LocalAgentRuntime {
|
|
|
194
218
|
}
|
|
195
219
|
file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
|
|
196
220
|
history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
|
|
197
|
-
async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false }) {
|
|
221
|
+
async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false, onText }) {
|
|
198
222
|
const images = readImageAttachments(attachments);
|
|
199
223
|
const text = typeof message === 'string' ? message : '';
|
|
200
224
|
if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || (!text.trim() && !images.length)) throw new Error('A session, message, and idempotency key are required.');
|
|
@@ -222,8 +246,9 @@ export class LocalAgentRuntime {
|
|
|
222
246
|
let scratch;
|
|
223
247
|
try {
|
|
224
248
|
scratch = images.length ? writeTurnImages(images) : null;
|
|
225
|
-
const result = resumedReply || await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, imagePaths: scratch?.paths || [] }, { signal: controller.signal });
|
|
249
|
+
const result = resumedReply || await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, runtimeTransport: state.runtimeTransport, imagePaths: scratch?.paths || [] }, { signal: controller.signal, onText });
|
|
226
250
|
state.runtimeSessionId = result.sessionId;
|
|
251
|
+
if (result.transport) state.runtimeTransport = result.transport;
|
|
227
252
|
state.messages.push({ role: 'user', content: userContent(text, images), timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
|
|
228
253
|
state.receipts[receiptKey] = { message, result };
|
|
229
254
|
writePrivateJson(this.file(sessionKey), state);
|
|
@@ -249,6 +274,25 @@ export class LocalAgentRuntime {
|
|
|
249
274
|
return aborted;
|
|
250
275
|
}
|
|
251
276
|
socket() { return new LocalAgentSocket(this); }
|
|
277
|
+
close() { if (this.config.runtime === 'hermes') resetHermesAcp(this.config); }
|
|
278
|
+
/** Gets Hermes ready before a message arrives: at bridge start and when a call opens. */
|
|
279
|
+
warm() {
|
|
280
|
+
if (this.config.runtime !== 'hermes' || !usesHermesAcp(this.config)) return Promise.resolve();
|
|
281
|
+
return warmHermesAcp(this.config, this.recentAcpSessions(2)).catch(() => {});
|
|
282
|
+
}
|
|
283
|
+
recentAcpSessions(limit) {
|
|
284
|
+
const directory = path.join(this.directory, 'conversations');
|
|
285
|
+
let files;
|
|
286
|
+
try { files = fs.readdirSync(directory).filter((name) => name.endsWith('.json')); } catch { return []; }
|
|
287
|
+
return files
|
|
288
|
+
.map((name) => { const file = path.join(directory, name); try { return { file, modified: fs.statSync(file).mtimeMs }; } catch { return null; } })
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.sort((a, b) => b.modified - a.modified)
|
|
291
|
+
.map(({ file }) => readJson(file, {}))
|
|
292
|
+
.filter((state) => state.runtimeTransport === 'acp' && state.runtimeSessionId)
|
|
293
|
+
.slice(0, limit)
|
|
294
|
+
.map((state) => state.runtimeSessionId);
|
|
295
|
+
}
|
|
252
296
|
}
|
|
253
297
|
|
|
254
298
|
class LocalAgentSocket extends EventEmitter {
|
|
@@ -282,7 +326,9 @@ class LocalAgentSocket extends EventEmitter {
|
|
|
282
326
|
if (req.method !== 'chat.send') return fail(`Method ${req.method} is not supported by this agent adapter.`);
|
|
283
327
|
const runId = params.idempotencyKey || req.id || randomUUID();
|
|
284
328
|
reply({ runId, status: 'started' });
|
|
285
|
-
|
|
329
|
+
// Streaming runtimes send the reply so far as it grows, the shape OpenClaw's gateway uses.
|
|
330
|
+
const onText = (text) => this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'delta', message: { role: 'assistant', content: [{ type: 'text', text }], timestamp: Date.now() } } });
|
|
331
|
+
this.runtime.turn({ ...params, idempotencyKey: runId, onText }).then((result) => {
|
|
286
332
|
this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'final', message: { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() } } });
|
|
287
333
|
}, (err) => {
|
|
288
334
|
this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'error', errorMessage: err.message, ...(typeof err.runtimeReady === 'boolean' ? { runtimeReady: err.runtimeReady } : {}) } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oomi-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Connect OpenClaw, Hermes, Grok Bot, and other agents to Oomi with verified runtime adapters",
|
|
5
5
|
"bin": {
|
|
6
6
|
"oomi": "bin/oomi-ai.js"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"url": "https://github.com/crispcode-io/oomi/issues"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/localAgent.js && node --check lib/hermesGateway.js && node --check lib/grokbotRuntime.js && node --check lib/connectionSetup.js && node --check lib/processSupervisor.js",
|
|
52
|
+
"check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js && node --check lib/bridgeMaintenance.js && node --check lib/localAgent.js && node --check lib/hermesGateway.js && node --check lib/hermesAcp.js && node --check lib/grokbotRuntime.js && node --check lib/connectionSetup.js && node --check lib/processSupervisor.js",
|
|
53
53
|
"test": "node --test test/*.test.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"lib/bridgeService.js",
|
|
81
81
|
"lib/bridgeMaintenance.js",
|
|
82
82
|
"lib/hermesGateway.js",
|
|
83
|
+
"lib/hermesAcp.js",
|
|
83
84
|
"lib/grokbotRuntime.js",
|
|
84
85
|
"lib/connectionSetup.js",
|
|
85
86
|
"lib/processSupervisor.js",
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -188,7 +188,7 @@ oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
|
188
188
|
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, Hermes
|
|
191
|
+
Use the user's app URL when provided. Hermes supports `--profile <profile>` and `--resume <exact-session-id>`. Without `--resume`, Oomi starts a persistent conversation with the same configured agent. Do not use `latest` or claim that an existing chat session was transferred unless its exact ID was selected. With oomi-ai 0.8.2+, an already-open Hermes conversation uses its existing local gateway owner and exact profile/session. Oomi attaches as another client; it never closes desktop, clears history, deletes a session lock, or substitutes a new chat. When no live owner exists, an Oomi-owned conversation (no `--resume`) runs on a warm `hermes acp` process with oomi-ai 0.9.0+: replies stream as they are written and stop when the person interrupts. From 0.9.1 the bridge starts Hermes and reopens recent conversations ahead of time (at bridge start and when a call opens), so the first message doesn't wait for Hermes to start. Hermes permission requests are denied, as `chat --quiet` denies dangerous commands; ordinary file edits run as they do in quiet mode. A conversation moving from the CLI starts a new Hermes session that receives the recent Oomi history. `--resume` conversations, Hermes without ACP, and `--hermes-transport cli` use `chat --quiet --query-file -` and resume the exact returned `session_id` on subsequent turns.
|
|
192
192
|
|
|
193
193
|
Shared desktop conversations require Hermes's loopback WebSocket gateway, active-session discovery, and durable prompt/reply row IDs. Busy conversations ask you to retry after the current task. If a simultaneous desktop send queues your message without a reply receipt, or the connection drops after sending, check the same Hermes chat before resending. Oomi cannot safely repeat an uncertain action. Human approvals stay in the existing Hermes desktop UI. Shared photo submission is not yet supported; use Hermes desktop for that photo. Older or terminal-only owners without a shared gateway receive an actionable error, never a competing process. The shared owner keeps its existing environment, so use the explicit Oomi `--connection NAME` for account commands.
|
|
194
194
|
|