oomi-ai 0.3.8 → 0.4.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/README.md +51 -40
- package/agent_instructions.md +36 -138
- package/bin/oomi-ai.js +198 -35
- package/lib/gatewayProbe.js +60 -0
- package/lib/localAgent.js +234 -0
- package/lib/personaApiClient.js +6 -2
- package/package.json +5 -3
- package/skills/oomi/SKILL.md +51 -66
- package/skills/oomi/agent_instructions.md +149 -69
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export function writePrivateJson(file, data) {
|
|
9
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
10
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
11
|
+
fs.writeFileSync(temporary, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
12
|
+
fs.renameSync(temporary, file);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readJson(file, fallback = {}) {
|
|
16
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) {
|
|
17
|
+
if (err.code === 'ENOENT') return fallback;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function localAgentConfig(flags) {
|
|
23
|
+
const runtime = String(flags.runtime || '');
|
|
24
|
+
if (!['hermes', 'command'].includes(runtime)) throw new Error('Choose --runtime hermes or --runtime command.');
|
|
25
|
+
let command = ['hermes'];
|
|
26
|
+
if (runtime === 'command') {
|
|
27
|
+
try { command = JSON.parse(flags.command); } catch { throw new Error('--command must be a JSON argv array, e.g. ["/path/to/adapter"].'); }
|
|
28
|
+
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.');
|
|
29
|
+
}
|
|
30
|
+
if (flags.resume === 'latest') throw new Error('Use an exact Hermes session ID, never latest.');
|
|
31
|
+
let profile = flags.profile || '';
|
|
32
|
+
let hermesHome;
|
|
33
|
+
if (runtime === 'hermes') {
|
|
34
|
+
const nativeHome = process.platform === 'win32' && process.env.LOCALAPPDATA
|
|
35
|
+
? path.join(process.env.LOCALAPPDATA, 'hermes') : path.join(os.homedir(), '.hermes');
|
|
36
|
+
const configuredHome = path.resolve(flags['hermes-home'] || process.env.HERMES_HOME || nativeHome);
|
|
37
|
+
const isProfile = path.basename(path.dirname(configuredHome)) === 'profiles';
|
|
38
|
+
hermesHome = isProfile ? path.dirname(path.dirname(configuredHome)) : configuredHome;
|
|
39
|
+
if (!profile && isProfile) profile = path.basename(configuredHome);
|
|
40
|
+
if (!profile) {
|
|
41
|
+
try { profile = fs.readFileSync(path.join(hermesHome, 'active_profile'), 'utf8').trim(); }
|
|
42
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
43
|
+
}
|
|
44
|
+
profile ||= 'default';
|
|
45
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) throw new Error('Specify a valid Hermes --profile.');
|
|
46
|
+
}
|
|
47
|
+
return { runtime, command, profile, hermesHome, resume: flags.resume || '', cwd: path.resolve(flags.cwd || process.cwd()) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// No shell interpolation. Message bodies travel over stdin, never as executable code.
|
|
51
|
+
export function runLocalAgent(config, request, { signal, timeoutMs = 180000 } = {}) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const args = config.command.slice(1);
|
|
54
|
+
if (config.runtime === 'hermes') {
|
|
55
|
+
if (config.profile) args.push('--profile', config.profile);
|
|
56
|
+
args.push('chat', '--quiet', '--query-file', '-');
|
|
57
|
+
if (request.runtimeSessionId) args.push('--resume', request.runtimeSessionId);
|
|
58
|
+
}
|
|
59
|
+
const child = spawn(config.command[0], args, { env: config.hermesHome ? { ...process.env, HERMES_HOME: config.hermesHome } : process.env, cwd: config.cwd, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
60
|
+
let output = '';
|
|
61
|
+
let diagnostics = '';
|
|
62
|
+
child.stdout.setEncoding('utf8');
|
|
63
|
+
child.stderr.setEncoding('utf8');
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
let error;
|
|
66
|
+
const terminate = (signal) => {
|
|
67
|
+
try {
|
|
68
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
69
|
+
else child.kill(signal);
|
|
70
|
+
} catch { /* The child already exited. */ }
|
|
71
|
+
};
|
|
72
|
+
const kill = (reason) => {
|
|
73
|
+
if (error) return;
|
|
74
|
+
error = reason;
|
|
75
|
+
terminate('SIGTERM');
|
|
76
|
+
killer = setTimeout(() => terminate('SIGKILL'), 1000);
|
|
77
|
+
killer.unref();
|
|
78
|
+
};
|
|
79
|
+
let killer;
|
|
80
|
+
const abort = () => kill(new Error('Agent turn cancelled.'));
|
|
81
|
+
const timer = setTimeout(() => kill(new Error('Agent turn timed out.')), timeoutMs);
|
|
82
|
+
if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
|
|
83
|
+
child.stdout.on('data', (data) => {
|
|
84
|
+
bytes += Buffer.byteLength(data);
|
|
85
|
+
if (bytes > 4 * 1024 * 1024) { if (!error) kill(new Error('Agent output exceeded 4 MiB.')); return; }
|
|
86
|
+
output += data.toString();
|
|
87
|
+
});
|
|
88
|
+
// Diagnostics may contain credentials or tool output; never forward them to Oomi.
|
|
89
|
+
child.stderr.on('data', (data) => { diagnostics = (diagnostics + data).slice(-8192); });
|
|
90
|
+
child.stdin.on('error', () => {});
|
|
91
|
+
child.on('error', (err) => { error = new Error(`Could not start ${config.runtime} agent (${err.code || 'spawn failed'}). Check the executable and working directory.`); });
|
|
92
|
+
child.on('close', (code) => {
|
|
93
|
+
clearTimeout(timer); clearTimeout(killer); signal?.removeEventListener('abort', abort);
|
|
94
|
+
if (error) return reject(error);
|
|
95
|
+
if (code !== 0) return reject(new Error(`Agent exited with code ${code}. Check local runtime credentials and logs.`));
|
|
96
|
+
try {
|
|
97
|
+
const result = config.runtime === 'hermes'
|
|
98
|
+
? { text: output, sessionId: diagnostics.match(/(?:^|\n)session_id:\s*([A-Za-z0-9_-]+)\s*(?:\n|$)/)?.[1] }
|
|
99
|
+
: JSON.parse(output);
|
|
100
|
+
if (!result || (config.runtime === 'hermes' && !result.sessionId) || typeof result.text !== 'string' || !result.text.trim()) throw new Error();
|
|
101
|
+
resolve({ text: result.text.trim(), sessionId: result.session_id || result.sessionId || request.runtimeSessionId || '' });
|
|
102
|
+
} 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":"..."}.')); }
|
|
103
|
+
});
|
|
104
|
+
child.stdin.end(config.runtime === 'hermes' ? request.message : JSON.stringify(request) + '\n');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class LocalAgentRuntime {
|
|
109
|
+
constructor(config, directory, onHealth = () => {}) {
|
|
110
|
+
this.config = config;
|
|
111
|
+
this.directory = directory;
|
|
112
|
+
this.onHealth = onHealth;
|
|
113
|
+
this.queue = Promise.resolve();
|
|
114
|
+
this.active = new Map();
|
|
115
|
+
}
|
|
116
|
+
file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
|
|
117
|
+
history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
|
|
118
|
+
async turn({ sessionKey, message, idempotencyKey, attachments = [] }) {
|
|
119
|
+
if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || typeof message !== 'string' || !message.trim()) throw new Error('A session, message, and idempotency key are required.');
|
|
120
|
+
if (attachments.length) throw new Error('This adapter supports text messages. Attachments require a runtime adapter that supports them.');
|
|
121
|
+
const key = `${sessionKey}\0${idempotencyKey}`;
|
|
122
|
+
if (this.active.has(key)) {
|
|
123
|
+
const active = this.active.get(key);
|
|
124
|
+
if (active.message !== message) throw new Error('Idempotency key was reused for a different message.');
|
|
125
|
+
return active.promise;
|
|
126
|
+
}
|
|
127
|
+
const receiptKey = createHash('sha256').update(idempotencyKey).digest('hex');
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const promise = this.queue.then(async () => {
|
|
130
|
+
const state = this.history(sessionKey);
|
|
131
|
+
const previous = state.receipts[receiptKey];
|
|
132
|
+
if (previous?.message !== undefined && previous.message !== message) throw new Error('Idempotency key was reused for a different message.');
|
|
133
|
+
if (previous?.result) return previous.result;
|
|
134
|
+
// A crash after a side effect cannot safely be retried by running the agent again.
|
|
135
|
+
if (previous) throw new Error('The previous turn was interrupted. Check the agent before sending a new message.');
|
|
136
|
+
if (controller.signal.aborted) throw new Error('Agent turn cancelled.');
|
|
137
|
+
state.receipts[receiptKey] = { message, status: 'running' };
|
|
138
|
+
writePrivateJson(this.file(sessionKey), state);
|
|
139
|
+
try {
|
|
140
|
+
const result = await runLocalAgent(this.config, { sessionKey, message, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume }, { signal: controller.signal });
|
|
141
|
+
state.runtimeSessionId = result.sessionId;
|
|
142
|
+
state.messages.push({ role: 'user', content: message, timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
|
|
143
|
+
state.receipts[receiptKey] = { message, result };
|
|
144
|
+
writePrivateJson(this.file(sessionKey), state);
|
|
145
|
+
this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
|
|
146
|
+
return result;
|
|
147
|
+
} catch (err) { this.onHealth({ runtimeReady: false, status: 'degraded', lastErrorMessage: err.message }); throw err; }
|
|
148
|
+
});
|
|
149
|
+
this.queue = promise.catch(() => {});
|
|
150
|
+
this.active.set(key, { promise, controller, sessionKey, message, runId: idempotencyKey });
|
|
151
|
+
try { return await promise; } finally { this.active.delete(key); }
|
|
152
|
+
}
|
|
153
|
+
abort(sessionKey, runId) {
|
|
154
|
+
let aborted = false;
|
|
155
|
+
for (const entry of this.active.values()) if (entry.sessionKey === sessionKey && (!runId || entry.runId === runId)) { entry.controller.abort(); aborted = true; }
|
|
156
|
+
return aborted;
|
|
157
|
+
}
|
|
158
|
+
socket() { return new LocalAgentSocket(this); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class LocalAgentSocket extends EventEmitter {
|
|
162
|
+
constructor(runtime) {
|
|
163
|
+
super(); this.runtime = runtime; this.readyState = 0; this.connected = false;
|
|
164
|
+
setImmediate(() => { if (this.readyState !== 0) return; this.readyState = 1; this.emit('open'); });
|
|
165
|
+
}
|
|
166
|
+
frame(frame) { if (this.readyState === 1) this.emit('message', JSON.stringify(frame)); }
|
|
167
|
+
send(raw) {
|
|
168
|
+
// Real WebSockets deliver replies asynchronously; preserve bridge bookkeeping order.
|
|
169
|
+
setImmediate(() => {
|
|
170
|
+
try { this.handle(raw); } catch {
|
|
171
|
+
let id;
|
|
172
|
+
try { id = JSON.parse(raw).id; } catch { return; }
|
|
173
|
+
this.frame({ type: 'res', id, ok: false, error: { code: 'local_agent_error', message: 'Could not read local conversation state. Inspect this connection before retrying.' } });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
handle(raw) {
|
|
178
|
+
if (this.readyState !== 1) return;
|
|
179
|
+
let req;
|
|
180
|
+
try { req = JSON.parse(raw); } catch { return; }
|
|
181
|
+
if (req.type !== 'req') return;
|
|
182
|
+
const reply = (payload) => this.frame({ type: 'res', id: req.id, ok: true, payload });
|
|
183
|
+
const fail = (message) => this.frame({ type: 'res', id: req.id, ok: false, error: { code: 'local_agent_error', message } });
|
|
184
|
+
const params = req.params || {};
|
|
185
|
+
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 } }); }
|
|
186
|
+
if (!this.connected) return fail('Connect before sending requests.');
|
|
187
|
+
if (req.method === 'chat.history') return reply({ sessionKey: params.sessionKey, messages: this.runtime.history(params.sessionKey || '').messages.slice(-Math.min(params.limit || 200, 500)) });
|
|
188
|
+
if (req.method === 'chat.abort') return reply({ aborted: this.runtime.abort(params.sessionKey, params.runId) });
|
|
189
|
+
if (req.method !== 'chat.send') return fail(`Method ${req.method} is not supported by this agent adapter.`);
|
|
190
|
+
const runId = params.idempotencyKey || req.id || randomUUID();
|
|
191
|
+
reply({ runId, status: 'started' });
|
|
192
|
+
this.runtime.turn({ ...params, idempotencyKey: runId }).then((result) => {
|
|
193
|
+
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() } } });
|
|
194
|
+
}, (err) => {
|
|
195
|
+
this.frame({ type: 'event', event: 'chat', payload: { runId, sessionKey: params.sessionKey, state: 'error', errorMessage: err.message } });
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
close(code = 1000, reason = '') { if (this.readyState === 3) return; this.readyState = 3; this.emit('close', code, reason); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function startChannelWorker(state, runtime) {
|
|
202
|
+
let stopped = false;
|
|
203
|
+
let timer;
|
|
204
|
+
const post = async (action, body) => {
|
|
205
|
+
const response = await fetch(`${state.brokerHttp.replace(/\/$/, '')}/v1/channel/plugin/${action}`, {
|
|
206
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${state.deviceToken}` },
|
|
207
|
+
body: JSON.stringify(body), signal: AbortSignal.timeout(15000),
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) throw new Error(`Oomi message delivery failed (${response.status}).`);
|
|
210
|
+
return response.json();
|
|
211
|
+
};
|
|
212
|
+
const poll = async () => {
|
|
213
|
+
try {
|
|
214
|
+
const { messages = [] } = await post('poll', { limit: 1 });
|
|
215
|
+
for (const message of messages) {
|
|
216
|
+
let result;
|
|
217
|
+
try {
|
|
218
|
+
result = await runtime.turn({ sessionKey: message.sessionKey, message: message.content, idempotencyKey: message.messageId });
|
|
219
|
+
} catch {
|
|
220
|
+
await post('acks', { messageId: message.messageId, outcome: 'failed', failureCode: 'local_agent_failed' });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Persist the reply before acknowledging delivery. Cached receipts allow a safe
|
|
224
|
+
// retry after a network failure without repeating agent actions.
|
|
225
|
+
await post('messages', { messageId: `reply_${message.messageId}`, conversationKey: message.conversationKey, userId: message.userId, sessionKey: message.sessionKey, content: result.text, correlationId: message.correlationId, source: state.runtime });
|
|
226
|
+
await post('acks', { messageId: message.messageId, outcome: 'delivered' });
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
runtime.onHealth({ status: 'degraded', lastErrorMessage: err.message });
|
|
230
|
+
} finally { if (!stopped) timer = setTimeout(poll, 3000); }
|
|
231
|
+
};
|
|
232
|
+
timer = setTimeout(poll, 0);
|
|
233
|
+
return () => { stopped = true; clearTimeout(timer); for (const entry of runtime.active.values()) entry.controller.abort(); };
|
|
234
|
+
}
|
package/lib/personaApiClient.js
CHANGED
|
@@ -140,12 +140,16 @@ export function createPersonaApiClient({
|
|
|
140
140
|
});
|
|
141
141
|
},
|
|
142
142
|
|
|
143
|
-
getHealthContext() {
|
|
143
|
+
getHealthContext(options = {}) {
|
|
144
|
+
const query = new URLSearchParams();
|
|
145
|
+
for (const key of ['window', 'date', 'from', 'to', 'timezone', 'shape', 'groupBy', 'cursor']) {
|
|
146
|
+
if (typeof options[key] === 'string' && options[key].trim()) query.set(key, options[key].trim());
|
|
147
|
+
}
|
|
144
148
|
return getJson({
|
|
145
149
|
fetchImpl,
|
|
146
150
|
backendUrl: resolvedBackendUrl,
|
|
147
151
|
deviceToken: resolvedDeviceToken,
|
|
148
|
-
path:
|
|
152
|
+
path: `/v1/context/health${query.size ? `?${query}` : ''}`,
|
|
149
153
|
});
|
|
150
154
|
},
|
|
151
155
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oomi-ai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Connect OpenClaw, Hermes, and other local agents to Oomi with verified runtime adapters",
|
|
5
5
|
"bin": {
|
|
6
6
|
"oomi": "bin/oomi-ai.js"
|
|
7
7
|
},
|
|
@@ -70,6 +70,8 @@
|
|
|
70
70
|
"README.md",
|
|
71
71
|
"persona-app",
|
|
72
72
|
"skills/oomi/SKILL.md",
|
|
73
|
-
"skills/oomi/agent_instructions.md"
|
|
73
|
+
"skills/oomi/agent_instructions.md",
|
|
74
|
+
"lib/gatewayProbe.js",
|
|
75
|
+
"lib/localAgent.js"
|
|
74
76
|
]
|
|
75
77
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -1,95 +1,77 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: oomi
|
|
3
|
-
description:
|
|
3
|
+
description: Connect the current OpenClaw, Hermes, or other local agent to Oomi; verify replies and repair the selected connection.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Oomi
|
|
6
|
+
# Oomi Local Agent Operator Guide
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
- connect an OpenClaw machine to Oomi
|
|
10
|
-
- repair the Oomi plugin or bridge on a machine
|
|
11
|
-
- inspect managed chat or voice health
|
|
12
|
-
- inspect or update component-composed Oomi persona app state
|
|
13
|
-
- read approved Oomi backend context such as HealthKit summaries synced by the mobile app
|
|
14
|
-
- read permissioned Finance balances (no tokens, routing, or transactions)
|
|
15
|
-
- control the Oomi avatar with inline tags
|
|
8
|
+
Connect the agent the user is speaking to. Identify your runtime and profile first. Never choose OpenClaw merely because it is installed on the same machine as Hermes.
|
|
16
9
|
|
|
17
|
-
##
|
|
10
|
+
## Standard Connect Flow
|
|
18
11
|
|
|
19
|
-
|
|
12
|
+
Install or update the local package:
|
|
20
13
|
|
|
21
|
-
1. For a first-time install or missing local pairing state, run the bootstrap exactly once using the requested app URL:
|
|
22
14
|
```bash
|
|
23
|
-
|
|
15
|
+
npm install -g oomi-ai@latest
|
|
24
16
|
```
|
|
25
|
-
The bootstrap installs or updates `oomi-ai`, performs the one initial pair, installs the plugin, and starts the saved bridge detached. Do not pair again after it finishes.
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai
|
|
30
|
-
```
|
|
18
|
+
Run ONE command for your runtime:
|
|
31
19
|
|
|
32
|
-
3. Restart OpenClaw, then ensure the saved bridge is running:
|
|
33
|
-
```bash
|
|
34
|
-
oomi openclaw bridge ensure --detach
|
|
35
|
-
```
|
|
36
|
-
If stale:
|
|
37
|
-
```bash
|
|
38
|
-
oomi openclaw bridge restart --detach
|
|
39
|
-
```
|
|
40
|
-
On macOS, prefer supervised mode:
|
|
41
20
|
```bash
|
|
42
|
-
|
|
43
|
-
|
|
21
|
+
# OpenClaw (reuses existing default OpenClaw pairing)
|
|
22
|
+
oomi connect --runtime openclaw --app-url https://www.oomi.ai
|
|
44
23
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
24
|
+
# Hermes (separate connection, no OpenClaw required)
|
|
25
|
+
oomi connect --runtime hermes --name hermes --app-url https://www.oomi.ai
|
|
26
|
+
|
|
27
|
+
# Another local agent, through its own adapter
|
|
28
|
+
oomi connect --runtime command --name my-agent --command '["/absolute/path/to/adapter"]' --app-url https://www.oomi.ai
|
|
48
29
|
```
|
|
49
30
|
|
|
50
|
-
|
|
31
|
+
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. Hermes uses `chat --quiet --query-file -`, reads the final reply from stdout and the exact `session_id` from stderr, and resumes that ID on subsequent turns.
|
|
32
|
+
|
|
33
|
+
The command verifies a real local reply, reuses saved credentials or pairs once, starts the bridge, waits for Oomi's subscription, then creates a single-use Auth Link. Failed verification means setup is incomplete: repair the selected runtime, never substitute a different one.
|
|
34
|
+
|
|
35
|
+
The installer is an alternative to npm plus connect, and requires an explicit runtime:
|
|
36
|
+
|
|
51
37
|
```bash
|
|
52
|
-
|
|
53
|
-
openclaw plugins install oomi-ai@latest
|
|
54
|
-
oomi openclaw refresh --skip-version-check
|
|
38
|
+
curl -fsSL https://www.oomi.ai/install.sh | bash -s -- --runtime hermes --name hermes
|
|
55
39
|
```
|
|
56
40
|
|
|
57
|
-
|
|
41
|
+
## Required User-Facing Result
|
|
42
|
+
|
|
43
|
+
Return the verified runtime/profile and the printed Auth Link. The human opens the link, signs in, and approves ownership. Only after a message receives a reply in Oomi can you claim end-to-end delivery works. Never expose raw tokens, pairing codes, or gateway credentials.
|
|
58
44
|
|
|
59
|
-
##
|
|
45
|
+
## Repair and Reconnect
|
|
60
46
|
|
|
61
|
-
Use
|
|
47
|
+
Use the SAME connection name from setup. Omit `--name` for default OpenClaw.
|
|
62
48
|
|
|
63
49
|
```bash
|
|
64
|
-
oomi
|
|
65
|
-
oomi
|
|
66
|
-
oomi
|
|
67
|
-
|
|
68
|
-
tail -f ~/.openclaw/logs/gateway.log
|
|
69
|
-
tail -f ~/.openclaw/logs/gateway.err.log
|
|
50
|
+
oomi status --name hermes --json
|
|
51
|
+
oomi verify --name hermes
|
|
52
|
+
oomi bridge restart --name hermes --detach
|
|
53
|
+
oomi invite --name hermes --app-url https://www.oomi.ai
|
|
70
54
|
```
|
|
71
55
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
56
|
+
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses the saved pairing and verifies the runtime again. A different command, profile, or working directory requires a new connection name, so one agent cannot silently replace another. Named connections store private state and logs under `~/.oomi/connections/<name>`; default OpenClaw keeps `~/.openclaw`. A detached worker requires the computer to remain awake and does not start automatically after reboot. Configure the host's process supervisor when persistence across reboot is needed.
|
|
57
|
+
|
|
58
|
+
`connected` describes the broker transport. Inspect `runtimeReady`, `verifiedAt`, and `lastReplyAt` separately. A process ID, Auth Link, or broker connection alone does not prove the agent is answering.
|
|
59
|
+
|
|
60
|
+
## Other Runtime Adapter Contract
|
|
61
|
+
|
|
62
|
+
`--command` is a JSON argv array, not a shell command. Write an adapter against the selected agent's documented API or noninteractive CLI. The executable reads one JSON object from stdin:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{"sessionKey":"...","message":"...","idempotencyKey":"...","history":[],"runtimeSessionId":"..."}
|
|
66
|
+
```
|
|
79
67
|
|
|
80
|
-
|
|
68
|
+
It writes `{"text":"final reply","sessionId":"optional-runtime-session"}` to stdout and exits zero on success. Errors must exit nonzero; diagnostics go to stderr. Resume `runtimeSessionId`, or use `history` for stateless runtimes. Never bypass the agent's existing approval rules. The adapter supports text (including voice transcripts); attachments are rejected explicitly. A failed or interrupted action is not silently repeated. Check the agent before sending a new message after a failure.
|
|
81
69
|
|
|
82
|
-
|
|
83
|
-
- Cause: multiple discoverable `oomi-ai` installs
|
|
84
|
-
- Action: remove stale plugin copies and reinstall once
|
|
70
|
+
## OpenClaw Channel Extension
|
|
85
71
|
|
|
86
|
-
|
|
87
|
-
- Cause: gateway request ordering broke
|
|
88
|
-
- Action: update `oomi-ai`, restart the bridge, confirm only one bridge worker exists
|
|
72
|
+
Managed web text and voice connect through the local gateway. The optional OpenClaw `oomi-ai` channel plugin handles agent-initiated channel output. If you need that path, install the plugin, enable its entry in OpenClaw, apply `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai` locally, and restart OpenClaw. Never share that secret config with the user. Disabling the plugin does not explain a gateway protocol rejection.
|
|
89
73
|
|
|
90
|
-
|
|
91
|
-
- Cause: the voice turn reached Oomi, but the managed gateway or OpenClaw run failed later
|
|
92
|
-
- Action: inspect `gateway.log`, `gateway.err.log`, and the session JSONL for that run
|
|
74
|
+
OpenClaw gateway protocols 3 and 4 are negotiated independently of the broker. For an existing install affected by a protocol mismatch, update `oomi-ai`, then run `oomi connect --runtime openclaw --app-url https://www.oomi.ai`.
|
|
93
75
|
|
|
94
76
|
## Persona App State Tools
|
|
95
77
|
|
|
@@ -119,10 +101,13 @@ oomi context health --json
|
|
|
119
101
|
```
|
|
120
102
|
|
|
121
103
|
Rules:
|
|
122
|
-
-
|
|
123
|
-
-
|
|
104
|
+
- For historical questions use `oomi context health --window day|week|month|year|all|range --json`. Add `--date YYYY-MM-DD`, or `--from YYYY-MM-DD --to YYYY-MM-DD` for a range. Week is the seven days ending on the date; month/year are calendar periods.
|
|
105
|
+
- Use `--shape summary|series|both` and `--group-by day|week|month|year` to fit the request. Follow `healthHistory.nextCursor` with `--cursor` and the same query. `all` covers imported history only. Missing days are unknown, never zero.
|
|
106
|
+
- Answer historical questions from `healthHistory.summary` and `.series` when `.agentGuidance.canAnswerFromContext` is true. Stored history remains usable when the phone is offline or the current snapshot is stale.
|
|
107
|
+
- For current questions answer from `healthContext.summary`, `.recentDays`, and `.derived`.
|
|
108
|
+
- For current questions respect `healthContext.agentGuidance.canAnswerFromContext`.
|
|
124
109
|
- if `healthContext.agentGuidance.recommendedCommand` is present, run it before answering current/latest health questions
|
|
125
|
-
- if context is stale or needs sync, run `oomi context health sync --wait --json`; answer only after fresh context is uploaded, or tell the user the phone sync is still pending if it times out
|
|
110
|
+
- For current/latest questions, if context is stale or needs sync, run `oomi context health sync --wait --json`; answer only after fresh context is uploaded, or tell the user the phone sync is still pending if it times out
|
|
126
111
|
- if context is missing, denied, disabled, or unavailable, follow `healthContext.repair`
|
|
127
112
|
- never present stale HealthKit values as current values
|
|
128
113
|
- do not ask HealthKit directly; the Oomi mobile app syncs approved HealthKit data to the backend
|