oomi-ai 0.3.7 → 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.
@@ -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
+ }
@@ -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: '/v1/context/health',
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.3.7",
4
- "description": "Managed Oomi channel, bridge, voice, and persona app API tooling for OpenClaw",
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
  }
@@ -1,95 +1,77 @@
1
1
  ---
2
2
  name: oomi
3
- description: Support Oomi OpenClaw installs, bridge health, managed chat and voice setup, persona app tools, and avatar control.
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 Skill
6
+ # Oomi Local Agent Operator Guide
7
7
 
8
- Use this skill when you need to:
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
- ## Primary Operator Workflow
10
+ ## Standard Connect Flow
18
11
 
19
- If the user wants OpenClaw connected to Oomi:
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
- curl -fsSL https://www.oomi.ai/install.sh | bash
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
- 2. Print config guidance and apply the `channels.oomi.accounts.default` config:
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
- oomi openclaw bridge service install
43
- ```
21
+ # OpenClaw (reuses existing default OpenClaw pairing)
22
+ oomi connect --runtime openclaw --app-url https://www.oomi.ai
44
23
 
45
- 4. If the Auth Link printed by bootstrap is missing or expired, create a replacement without re-pairing:
46
- ```bash
47
- oomi openclaw invite --app-url https://www.oomi.ai
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
- 5. If the machine is already paired, do not rerun bootstrap. Refresh the installed package and plugin without changing pairing state, then use `invite`, `bridge ensure`, or `bridge restart` as needed:
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
- pnpm add -g oomi-ai@latest
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
- If the user provides a specific app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
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
- ## Health Checks
45
+ ## Repair and Reconnect
60
46
 
61
- Use these when chat or voice is failing:
47
+ Use the SAME connection name from setup. Omit `--name` for default OpenClaw.
62
48
 
63
49
  ```bash
64
- oomi openclaw bridge ps
65
- oomi openclaw bridge service status
66
- oomi openclaw status
67
- tail -f ~/.openclaw/logs/oomi-bridge-live.log
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
- Interpret bridge states like this:
73
- - `starting`: booting or waiting for managed subscription
74
- - `connected`: ready for managed traffic
75
- - `reconnecting`: retry scheduled after transport failure
76
- - `degraded`: bridge caught a runtime fault but is still alive
77
- - `error`: startup or auth failure blocked operation
78
- - `stopped`: not running or intentionally shut down
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
- ## Common Failures
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
- ### Duplicate plugin id
83
- - Cause: multiple discoverable `oomi-ai` installs
84
- - Action: remove stale plugin copies and reinstall once
70
+ ## OpenClaw Channel Extension
85
71
 
86
- ### `invalid handshake: first request must be connect`
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
- ### STT works but the assistant does not reply
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
- - answer only from `healthContext.summary` and `healthContext.derived`
123
- - respect `healthContext.agentGuidance.canAnswerFromContext`
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
@@ -138,7 +123,8 @@ oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --js
138
123
  Rules:
139
124
  - notify only for things worth interrupting the user for (a reminder they asked for, a finished task, something time-sensitive); say everything else in chat
140
125
  - keep it short and specific; the title defaults to your agent name
141
- - tapping it opens the Oomi app to the conversation, so follow up in chat
126
+ - tapping it opens the Oomi app to the conversation, where the message also appears
127
+ - messages you send on the Oomi channel already notify the phone; do not also run `oomi notify` for the same message
142
128
  - if `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying
143
129
  - if rate limited, wait `retryAfterSeconds` before trying again
144
130