oomi-ai 0.3.5 → 0.3.6
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 +30 -6
- package/agent_instructions.md +12 -25
- package/bin/oomi-ai.js +106 -93
- package/lib/modelRelayClient.js +150 -0
- package/lib/openclawProfile.js +155 -1
- package/lib/personaApiClient.js +9 -0
- package/package.json +2 -1
- package/persona-app/README.md +2 -3
- package/persona-app/registry/v1.json +93 -40
- package/skills/oomi/SKILL.md +24 -32
- package/skills/oomi/agent_instructions.md +11 -21
package/README.md
CHANGED
|
@@ -145,12 +145,40 @@ Optional fields:
|
|
|
145
145
|
- `defaultSessionKey`
|
|
146
146
|
- `requestTimeoutMs`
|
|
147
147
|
|
|
148
|
+
Hosted Oomi-managed agents also include a `modelRelay` block under the default account. When enabled, `oomi-ai` applies an OpenClaw custom provider named `oomi-managed` that points at Oomi's OpenAI-compatible metered relay:
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"channels": {
|
|
153
|
+
"oomi": {
|
|
154
|
+
"accounts": {
|
|
155
|
+
"default": {
|
|
156
|
+
"modelRelay": {
|
|
157
|
+
"enabled": true,
|
|
158
|
+
"backendUrl": "https://api.oomi.ai",
|
|
159
|
+
"openaiBasePath": "/v1/model/relay/openai",
|
|
160
|
+
"openclawProviderId": "oomi-managed",
|
|
161
|
+
"openclawModelRef": "oomi-managed/google/gemini-2.5-flash",
|
|
162
|
+
"provider": "openrouter",
|
|
163
|
+
"model": "google/gemini-2.5-flash",
|
|
164
|
+
"userId": "usr_...",
|
|
165
|
+
"deviceId": "oomi-hosted-...",
|
|
166
|
+
"internalKeyEnv": "OOMI_HOSTED_BOOTSTRAP_INTERNAL_KEY"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The config stores only the internal-key environment variable name. The secret value stays in the runtime environment.
|
|
176
|
+
|
|
148
177
|
## Persona App API Tools
|
|
149
178
|
|
|
150
179
|
Component-composed persona apps are the default Oomi mini-app path. Use these commands when the user asks about or updates an existing Oomi persona app such as Fitness Today:
|
|
151
180
|
|
|
152
181
|
```bash
|
|
153
|
-
oomi fitness show --json
|
|
154
182
|
oomi persona-apps list --json
|
|
155
183
|
oomi persona-apps show fitness-today --json
|
|
156
184
|
oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
|
|
@@ -159,21 +187,17 @@ oomi persona-apps apply-action fitness-today --action fitness.complete_workout -
|
|
|
159
187
|
Rules for agents:
|
|
160
188
|
|
|
161
189
|
- Answer from returned `appState.bindings`, not from memory.
|
|
162
|
-
- For Fitness questions, prefer `oomi fitness show --json` because it returns the Fitness mini-app state and approved health context together.
|
|
163
190
|
- Apply updates only through approved `persona-apps apply-action` actions.
|
|
164
191
|
- If the persona app does not exist, tell the user to add it from the Oomi client first.
|
|
165
192
|
- Do not create local persona UI projects.
|
|
166
193
|
|
|
167
|
-
For the Fitness slice:
|
|
194
|
+
For the Fitness v0 slice:
|
|
168
195
|
|
|
169
196
|
```bash
|
|
170
|
-
oomi persona-apps apply-action fitness-today --action fitness.set_goal --payload-json '{"goalId":"move_more"}' --json
|
|
171
197
|
oomi persona-apps apply-action fitness-today --action fitness.complete_goal --payload-json '{"goalId":"easy-run"}' --json
|
|
172
198
|
oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
|
|
173
199
|
```
|
|
174
200
|
|
|
175
|
-
Supported `fitness.set_goal` values are `move_more`, `sleep_better`, and `build_workout_habit`.
|
|
176
|
-
|
|
177
201
|
## Context Tools
|
|
178
202
|
|
|
179
203
|
Use context tools when the user asks a data-backed question that should come from Oomi-approved mobile permissions, not model memory. For health and fitness questions, read the latest backend HealthKit context first:
|
package/agent_instructions.md
CHANGED
|
@@ -12,7 +12,6 @@ Get OpenClaw into a state where:
|
|
|
12
12
|
- Oomi device-backed voice can start cleanly
|
|
13
13
|
- the bridge is supervised or restartable
|
|
14
14
|
- component-composed persona apps can be inspected and updated through Oomi API tools
|
|
15
|
-
- Fitness state can be inspected with one command that combines mini-app state and approved health context
|
|
16
15
|
- approved user context, such as HealthKit data synced by the Oomi mobile app, can be read through Oomi backend context tools
|
|
17
16
|
- the user never has to paste raw pair identifiers or gateway secrets manually when managed connect is available
|
|
18
17
|
|
|
@@ -20,27 +19,24 @@ Get OpenClaw into a state where:
|
|
|
20
19
|
|
|
21
20
|
If the user asks to connect OpenClaw to Oomi chat or voice:
|
|
22
21
|
|
|
23
|
-
1.
|
|
22
|
+
1. For a first-time install or missing local pairing state, run the bootstrap exactly once using the requested app URL:
|
|
24
23
|
- `curl -fsSL https://www.oomi.ai/install.sh | bash`
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
3. Pair the device without starting a legacy one-off flow:
|
|
29
|
-
- `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
|
|
30
|
-
4. Install the OpenClaw plugin:
|
|
31
|
-
- `openclaw plugins install oomi-ai@latest`
|
|
32
|
-
5. Print the config block and secrets:
|
|
24
|
+
- The bootstrap installs or updates `oomi-ai`, performs the one initial pair, installs the plugin, and starts the saved bridge in detached mode.
|
|
25
|
+
- Do not pair again after the bootstrap finishes.
|
|
26
|
+
2. Print the config block and secrets:
|
|
33
27
|
- `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
3. Apply the printed `channels.oomi.accounts.default` config.
|
|
29
|
+
4. Restart OpenClaw.
|
|
30
|
+
5. Start or repair the bridge from saved pairing state:
|
|
37
31
|
- `oomi openclaw bridge ensure --detach`
|
|
38
32
|
- if stale: `oomi openclaw bridge restart --detach`
|
|
39
33
|
- on macOS, prefer supervision: `oomi openclaw bridge service install`
|
|
34
|
+
6. If the Auth Link printed by bootstrap is missing or expired, create a replacement without re-pairing:
|
|
35
|
+
- `oomi openclaw invite --app-url https://www.oomi.ai`
|
|
40
36
|
|
|
41
|
-
If the
|
|
37
|
+
If the machine is already paired, do not rerun bootstrap. Use `invite` for a fresh Auth Link and `bridge ensure` or `bridge restart` for runtime recovery.
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
If the user provides a specific app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
|
|
44
40
|
|
|
45
41
|
## Required User-Facing Result
|
|
46
42
|
|
|
@@ -249,17 +245,8 @@ When a user asks about an existing persona app:
|
|
|
249
245
|
4. To update state, use a validated action: `oomi persona-apps apply-action <slug> --action <action-id> --payload-json '<json>' --json`.
|
|
250
246
|
5. If `persona-apps list` returns no app for the user's request, tell the user they can add that persona from the Oomi client so the client can collect the right permissions and data sources.
|
|
251
247
|
|
|
252
|
-
For
|
|
248
|
+
For the Fitness v0 slice:
|
|
253
249
|
|
|
254
|
-
```bash
|
|
255
|
-
oomi fitness show --json
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
It returns the linked Fitness persona app plus the latest approved health context in one payload.
|
|
259
|
-
|
|
260
|
-
For the Fitness slice:
|
|
261
|
-
|
|
262
|
-
- Use `fitness.set_goal` with payload `{"goalId":"move_more"}`, `{"goalId":"sleep_better"}`, or `{"goalId":"build_workout_habit"}` to set the active focus.
|
|
263
250
|
- Use `fitness.complete_goal` with payload `{"goalId":"easy-run"}` to mark a known goal complete.
|
|
264
251
|
- Use `fitness.complete_workout` with payload like `{"goalId":"easy-run","minutes":20}` when the user says they finished a workout.
|
|
265
252
|
|
package/bin/oomi-ai.js
CHANGED
|
@@ -233,9 +233,6 @@ Commands:
|
|
|
233
233
|
persona-apps apply-action <slug>
|
|
234
234
|
Apply an approved persona app action through the Oomi backend.
|
|
235
235
|
|
|
236
|
-
fitness show
|
|
237
|
-
Show the linked Fitness persona app plus approved health context.
|
|
238
|
-
|
|
239
236
|
context health
|
|
240
237
|
Show the latest account-linked health context available to this paired OpenClaw device.
|
|
241
238
|
context health sync [--wait]
|
|
@@ -382,18 +379,6 @@ function findRepoRoot(startDir) {
|
|
|
382
379
|
return null;
|
|
383
380
|
}
|
|
384
381
|
|
|
385
|
-
function resolveRepoRoot(rootFlag) {
|
|
386
|
-
const explicitRoot =
|
|
387
|
-
typeof rootFlag === 'string' && rootFlag.trim()
|
|
388
|
-
? path.resolve(rootFlag.trim())
|
|
389
|
-
: '';
|
|
390
|
-
const repoRoot = explicitRoot || findRepoRoot(process.cwd()) || findRepoRoot(PACKAGE_ROOT);
|
|
391
|
-
if (!repoRoot) {
|
|
392
|
-
throw new Error('Could not locate repo root. Use --root <repo root>.');
|
|
393
|
-
}
|
|
394
|
-
return repoRoot;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
382
|
function resolveSkillSource(cliRoot) {
|
|
398
383
|
const packaged = path.join(PACKAGE_ROOT, 'skills', 'oomi');
|
|
399
384
|
if (fs.existsSync(packaged)) {
|
|
@@ -587,32 +572,8 @@ async function handleContextHealthCommand(flags = {}) {
|
|
|
587
572
|
printStructuredResult(payload, isTruthyFlag(flags.json));
|
|
588
573
|
}
|
|
589
574
|
|
|
590
|
-
async function
|
|
591
|
-
|
|
592
|
-
const listPayload = await client.listPersonaApps();
|
|
593
|
-
const personaApps = Array.isArray(listPayload?.personaApps) ? listPayload.personaApps : [];
|
|
594
|
-
const fitnessApp = personaApps.find((app) => app?.personaType === 'fitness') ||
|
|
595
|
-
personaApps.find((app) => app?.slug === 'fitness-today');
|
|
596
|
-
|
|
597
|
-
if (!fitnessApp?.slug) {
|
|
598
|
-
throw new Error('Fitness persona app is not linked. Ask the user to add Fitness from the Oomi client first.');
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const appPayload = await client.getPersonaApp({ slug: fitnessApp.slug });
|
|
602
|
-
let healthPayload = null;
|
|
603
|
-
let healthContextError = null;
|
|
604
|
-
try {
|
|
605
|
-
healthPayload = await client.getHealthContext();
|
|
606
|
-
} catch (err) {
|
|
607
|
-
healthContextError = err?.message || String(err);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
printStructuredResult({
|
|
611
|
-
ok: true,
|
|
612
|
-
personaApp: appPayload?.personaApp || null,
|
|
613
|
-
healthContext: healthPayload?.healthContext || null,
|
|
614
|
-
healthContextError,
|
|
615
|
-
}, isTruthyFlag(flags.json));
|
|
575
|
+
async function handleContextFinanceCommand(_flags = {}) {
|
|
576
|
+
throw new Error("oomi context finance is off until Gate A. Use the signed-in web Finance persona-app. Do not read bank balances from the connected OpenClaw shell.");
|
|
616
577
|
}
|
|
617
578
|
|
|
618
579
|
async function handleContextHealthSyncCommand(flags = {}) {
|
|
@@ -772,6 +733,28 @@ function readOpenclawGatewayConfig() {
|
|
|
772
733
|
};
|
|
773
734
|
}
|
|
774
735
|
|
|
736
|
+
function readOpenclawDefaultSessionKey() {
|
|
737
|
+
try {
|
|
738
|
+
const configPath = resolveOpenclawConfigPath();
|
|
739
|
+
if (!configPath || !fs.existsSync(configPath)) {
|
|
740
|
+
return '';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const parsed = JSON.parse(readFile(configPath));
|
|
744
|
+
const channels = parsed.channels || {};
|
|
745
|
+
const oomi = channels.oomi || {};
|
|
746
|
+
const accounts = oomi.accounts || {};
|
|
747
|
+
const defaultAccount = accounts.default || {};
|
|
748
|
+
const defaultSessionKey = typeof defaultAccount.defaultSessionKey === 'string'
|
|
749
|
+
? defaultAccount.defaultSessionKey.trim()
|
|
750
|
+
: '';
|
|
751
|
+
|
|
752
|
+
return defaultSessionKey;
|
|
753
|
+
} catch {
|
|
754
|
+
return '';
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
775
758
|
function resolveBridgeStatePath() {
|
|
776
759
|
return resolveOpenclawBridgeStatePath();
|
|
777
760
|
}
|
|
@@ -1080,6 +1063,25 @@ async function requestManagedPairCode({ appUrl, label }) {
|
|
|
1080
1063
|
return payload;
|
|
1081
1064
|
}
|
|
1082
1065
|
|
|
1066
|
+
async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
|
|
1067
|
+
const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/start`, {
|
|
1068
|
+
method: 'POST',
|
|
1069
|
+
headers: {
|
|
1070
|
+
'Content-Type': 'application/json',
|
|
1071
|
+
Authorization: `Bearer ${adminKey}`,
|
|
1072
|
+
},
|
|
1073
|
+
body: JSON.stringify({ label }),
|
|
1074
|
+
});
|
|
1075
|
+
const payload = await response.json().catch(() => ({}));
|
|
1076
|
+
if (!response.ok || !payload?.pairCode) {
|
|
1077
|
+
const message =
|
|
1078
|
+
(payload && typeof payload.error === 'string' && payload.error) ||
|
|
1079
|
+
`Broker pair start failed (${response.status})`;
|
|
1080
|
+
throw new Error(message);
|
|
1081
|
+
}
|
|
1082
|
+
return payload;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1083
1085
|
async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
|
|
1084
1086
|
const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
|
|
1085
1087
|
method: 'POST',
|
|
@@ -1115,6 +1117,35 @@ async function fetchManagedGatewayConfig({ appUrl }) {
|
|
|
1115
1117
|
return payload;
|
|
1116
1118
|
}
|
|
1117
1119
|
|
|
1120
|
+
function shouldRewriteLoopbackBrokerForAppHost(appHostname) {
|
|
1121
|
+
return String(appHostname || '').trim().toLowerCase() === 'host.docker.internal';
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function isLoopbackBrokerHostname(hostname) {
|
|
1125
|
+
const normalized = String(hostname || '').trim().toLowerCase();
|
|
1126
|
+
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1';
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function rewriteLoopbackBrokerUrlForAppHost(rawUrl, appUrl) {
|
|
1130
|
+
const value = String(rawUrl || '').trim();
|
|
1131
|
+
if (!value) return value;
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
const brokerUrl = new URL(value);
|
|
1135
|
+
const app = new URL(String(appUrl || '').trim());
|
|
1136
|
+
if (!isLoopbackBrokerHostname(brokerUrl.hostname)) {
|
|
1137
|
+
return value;
|
|
1138
|
+
}
|
|
1139
|
+
if (!shouldRewriteLoopbackBrokerForAppHost(app.hostname)) {
|
|
1140
|
+
return value;
|
|
1141
|
+
}
|
|
1142
|
+
brokerUrl.hostname = app.hostname;
|
|
1143
|
+
return brokerUrl.toString();
|
|
1144
|
+
} catch {
|
|
1145
|
+
return value;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1118
1149
|
function base64UrlEncode(value) {
|
|
1119
1150
|
return Buffer.from(value)
|
|
1120
1151
|
.toString('base64')
|
|
@@ -1773,48 +1804,6 @@ function printAssistantFinalDebugResult(result, asJson) {
|
|
|
1773
1804
|
}
|
|
1774
1805
|
}
|
|
1775
1806
|
|
|
1776
|
-
function resolveCommandFromPath(commandName) {
|
|
1777
|
-
const normalized = String(commandName || '').trim();
|
|
1778
|
-
if (!normalized) return '';
|
|
1779
|
-
try {
|
|
1780
|
-
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [normalized], {
|
|
1781
|
-
encoding: 'utf8',
|
|
1782
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1783
|
-
});
|
|
1784
|
-
if (probe.status !== 0) return '';
|
|
1785
|
-
const firstLine = String(probe.stdout || '')
|
|
1786
|
-
.split(/\r?\n/)
|
|
1787
|
-
.map((line) => line.trim())
|
|
1788
|
-
.find(Boolean);
|
|
1789
|
-
return firstLine || '';
|
|
1790
|
-
} catch {
|
|
1791
|
-
return '';
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
function resolveExecutable(candidates = []) {
|
|
1796
|
-
for (const candidate of candidates) {
|
|
1797
|
-
if (!candidate) continue;
|
|
1798
|
-
const value = String(candidate).trim();
|
|
1799
|
-
if (!value) continue;
|
|
1800
|
-
if (path.isAbsolute(value) && fs.existsSync(value)) {
|
|
1801
|
-
return value;
|
|
1802
|
-
}
|
|
1803
|
-
if (value.includes(path.sep) || value.includes('/')) {
|
|
1804
|
-
const resolved = path.resolve(value);
|
|
1805
|
-
if (fs.existsSync(resolved)) {
|
|
1806
|
-
return resolved;
|
|
1807
|
-
}
|
|
1808
|
-
continue;
|
|
1809
|
-
}
|
|
1810
|
-
const fromPath = resolveCommandFromPath(value);
|
|
1811
|
-
if (fromPath) {
|
|
1812
|
-
return fromPath;
|
|
1813
|
-
}
|
|
1814
|
-
}
|
|
1815
|
-
return '';
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
1807
|
async function handleOpenclawDebugCommand(action, flags) {
|
|
1819
1808
|
const normalizedAction = String(action || '').trim().toLowerCase();
|
|
1820
1809
|
const frameFile =
|
|
@@ -2558,11 +2547,13 @@ async function startOpenclawBridge(flags) {
|
|
|
2558
2547
|
if (!deviceToken) {
|
|
2559
2548
|
throw new Error('Broker pair claim did not return deviceToken.');
|
|
2560
2549
|
}
|
|
2550
|
+
const configSessionKey = readOpenclawDefaultSessionKey();
|
|
2561
2551
|
writeBridgeState({
|
|
2562
2552
|
brokerHttp,
|
|
2563
2553
|
brokerWs,
|
|
2564
2554
|
deviceId,
|
|
2565
2555
|
deviceToken,
|
|
2556
|
+
sessionKey: configSessionKey || 'agent:main:webchat:channel:oomi',
|
|
2566
2557
|
claimedAt: new Date().toISOString(),
|
|
2567
2558
|
expiresAt: claimed.expiresAt || null,
|
|
2568
2559
|
});
|
|
@@ -3837,7 +3828,24 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
3837
3828
|
|
|
3838
3829
|
console.log(`Pairing OpenClaw host with Oomi app: ${appUrl}`);
|
|
3839
3830
|
const managedConfig = await fetchManagedGatewayConfig({ appUrl });
|
|
3840
|
-
const
|
|
3831
|
+
const brokerHttp = rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerHttpUrl, appUrl);
|
|
3832
|
+
let pairStarted;
|
|
3833
|
+
try {
|
|
3834
|
+
pairStarted = await requestManagedPairCode({ appUrl, label });
|
|
3835
|
+
} catch (err) {
|
|
3836
|
+
const brokerAdminKey = String(process.env.OOMI_CHAT_BROKER_ADMIN_KEY || '').trim();
|
|
3837
|
+
if (!brokerAdminKey) {
|
|
3838
|
+
throw err;
|
|
3839
|
+
}
|
|
3840
|
+
console.warn(
|
|
3841
|
+
`[oomi] Managed pair start via app failed (${extractErrorMessage(err)}); falling back to direct broker pair/start.`
|
|
3842
|
+
);
|
|
3843
|
+
pairStarted = await requestBrokerPairCode({
|
|
3844
|
+
brokerHttp,
|
|
3845
|
+
adminKey: brokerAdminKey,
|
|
3846
|
+
label,
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3841
3849
|
const pairCode = String(pairStarted.pairCode || '').trim().toUpperCase();
|
|
3842
3850
|
if (!pairCode) {
|
|
3843
3851
|
throw new Error('Pairing code missing from backend response.');
|
|
@@ -3845,7 +3853,7 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
3845
3853
|
|
|
3846
3854
|
console.log(`Received pair code: ${pairCode}`);
|
|
3847
3855
|
const claimed = await claimBridgeDeviceToken({
|
|
3848
|
-
brokerHttp
|
|
3856
|
+
brokerHttp,
|
|
3849
3857
|
pairCode,
|
|
3850
3858
|
deviceId,
|
|
3851
3859
|
});
|
|
@@ -3854,9 +3862,11 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
3854
3862
|
throw new Error('Broker pair claim did not return deviceToken.');
|
|
3855
3863
|
}
|
|
3856
3864
|
|
|
3857
|
-
const brokerWs = String(
|
|
3865
|
+
const brokerWs = String(
|
|
3866
|
+
flags['broker-ws'] || rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerDeviceWsUrl, appUrl)
|
|
3867
|
+
).trim();
|
|
3858
3868
|
writeBridgeState({
|
|
3859
|
-
brokerHttp
|
|
3869
|
+
brokerHttp,
|
|
3860
3870
|
brokerWs,
|
|
3861
3871
|
deviceId,
|
|
3862
3872
|
deviceToken,
|
|
@@ -3866,7 +3876,7 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
3866
3876
|
});
|
|
3867
3877
|
|
|
3868
3878
|
const invite = await requestConnectInviteLink({
|
|
3869
|
-
backendHttp:
|
|
3879
|
+
backendHttp: brokerHttp,
|
|
3870
3880
|
appUrl,
|
|
3871
3881
|
sessionKey,
|
|
3872
3882
|
deviceToken,
|
|
@@ -3940,9 +3950,11 @@ async function createOpenclawInviteLink(flags) {
|
|
|
3940
3950
|
''
|
|
3941
3951
|
).trim();
|
|
3942
3952
|
const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
|
|
3953
|
+
const configSessionKey = readOpenclawDefaultSessionKey();
|
|
3943
3954
|
const sessionKey = String(
|
|
3944
3955
|
flags['session-key'] ||
|
|
3945
3956
|
process.env.OOMI_SESSION_KEY ||
|
|
3957
|
+
configSessionKey ||
|
|
3946
3958
|
bridgeState.sessionKey ||
|
|
3947
3959
|
'agent:main:webchat:channel:oomi'
|
|
3948
3960
|
).trim();
|
|
@@ -4403,11 +4415,6 @@ async function main() {
|
|
|
4403
4415
|
return;
|
|
4404
4416
|
}
|
|
4405
4417
|
|
|
4406
|
-
if (command === 'fitness' && subcommand === 'show') {
|
|
4407
|
-
await handleFitnessShowCommand(args.flags);
|
|
4408
|
-
return;
|
|
4409
|
-
}
|
|
4410
|
-
|
|
4411
4418
|
if (command === 'context' && subcommand === 'health') {
|
|
4412
4419
|
if (args.positionals[0] === 'sync') {
|
|
4413
4420
|
await handleContextHealthSyncCommand(args.flags);
|
|
@@ -4417,6 +4424,11 @@ async function main() {
|
|
|
4417
4424
|
return;
|
|
4418
4425
|
}
|
|
4419
4426
|
|
|
4427
|
+
if (command === 'context' && subcommand === 'finance') {
|
|
4428
|
+
await handleContextFinanceCommand(args.flags);
|
|
4429
|
+
return;
|
|
4430
|
+
}
|
|
4431
|
+
|
|
4420
4432
|
console.error(`Unknown command: ${command} ${subcommand || ''}`.trim());
|
|
4421
4433
|
usage();
|
|
4422
4434
|
process.exit(1);
|
|
@@ -4457,4 +4469,5 @@ export {
|
|
|
4457
4469
|
isGatewayRunStartedFrame,
|
|
4458
4470
|
isBridgeWorkerCommand,
|
|
4459
4471
|
parsePositiveInteger,
|
|
4472
|
+
rewriteLoopbackBrokerUrlForAppHost,
|
|
4460
4473
|
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const DEFAULT_ENDPOINT_PATH = '/v1/model/relay/chat';
|
|
2
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
|
|
3
|
+
const DEFAULT_REQUEST_ORIGIN = 'hosted_openclaw';
|
|
4
|
+
|
|
5
|
+
function toString(value, fallback = '') {
|
|
6
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toPositiveInteger(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
10
|
+
const parsed = Number.parseInt(String(value || ''), 10);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed < min) return fallback;
|
|
12
|
+
return Math.min(parsed, max);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeMessages(messages) {
|
|
16
|
+
return Array.isArray(messages)
|
|
17
|
+
? messages
|
|
18
|
+
.map((message) => {
|
|
19
|
+
if (!message || typeof message !== 'object') return null;
|
|
20
|
+
const role = toString(message.role);
|
|
21
|
+
const content = toString(message.content);
|
|
22
|
+
if (!role || !content) return null;
|
|
23
|
+
return { role, content };
|
|
24
|
+
})
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
: [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractModelRelayConfig(cfg = {}, accountId = '') {
|
|
30
|
+
const channel = cfg?.channels?.oomi || cfg?.oomi || cfg;
|
|
31
|
+
const accounts = channel && typeof channel.accounts === 'object' ? channel.accounts : {};
|
|
32
|
+
const defaultAccountId = toString(accountId, toString(channel?.defaultAccountId, 'default'));
|
|
33
|
+
const account = accounts[defaultAccountId] || accounts.default || {};
|
|
34
|
+
const relay = account?.modelRelay || cfg?.modelRelay || {};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
enabled: relay.enabled === true,
|
|
38
|
+
backendUrl: toString(relay.backendUrl, toString(account.backendUrl)),
|
|
39
|
+
endpointPath: toString(relay.endpointPath, DEFAULT_ENDPOINT_PATH),
|
|
40
|
+
provider: toString(relay.provider),
|
|
41
|
+
model: toString(relay.model),
|
|
42
|
+
requestOrigin: toString(relay.requestOrigin, DEFAULT_REQUEST_ORIGIN),
|
|
43
|
+
userId: toString(relay.userId),
|
|
44
|
+
deviceId: toString(relay.deviceId),
|
|
45
|
+
internalKeyEnv: toString(relay.internalKeyEnv),
|
|
46
|
+
requestTimeoutMs: toPositiveInteger(relay.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, {
|
|
47
|
+
min: 2000,
|
|
48
|
+
max: 120000,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildRelayUrl(backendUrl, endpointPath) {
|
|
54
|
+
const base = toString(backendUrl).replace(/\/+$/, '');
|
|
55
|
+
const path = `/${toString(endpointPath, DEFAULT_ENDPOINT_PATH).replace(/^\/+/, '')}`;
|
|
56
|
+
return `${base}${path}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveInternalKey({ relayConfig, env = process.env, internalKey = '' }) {
|
|
60
|
+
const direct = toString(internalKey);
|
|
61
|
+
if (direct) return direct;
|
|
62
|
+
|
|
63
|
+
const envName = toString(relayConfig.internalKeyEnv);
|
|
64
|
+
if (!envName) return '';
|
|
65
|
+
|
|
66
|
+
return toString(env?.[envName]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateModelRelayConfig(relayConfig, { env = process.env, internalKey = '' } = {}) {
|
|
70
|
+
const config = relayConfig || {};
|
|
71
|
+
if (!config.enabled) {
|
|
72
|
+
return { ok: false, error: 'Oomi model relay is disabled.' };
|
|
73
|
+
}
|
|
74
|
+
if (!config.backendUrl) {
|
|
75
|
+
return { ok: false, error: 'Oomi model relay backendUrl is required.' };
|
|
76
|
+
}
|
|
77
|
+
if (!config.userId) {
|
|
78
|
+
return { ok: false, error: 'Oomi model relay userId is required.' };
|
|
79
|
+
}
|
|
80
|
+
if (!resolveInternalKey({ relayConfig: config, env, internalKey })) {
|
|
81
|
+
return { ok: false, error: 'Oomi model relay internal key is required.' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function chatWithModelRelay({
|
|
88
|
+
cfg,
|
|
89
|
+
accountId = '',
|
|
90
|
+
messages,
|
|
91
|
+
maxOutputTokens,
|
|
92
|
+
temperature,
|
|
93
|
+
conversationKey = '',
|
|
94
|
+
requestOrigin = '',
|
|
95
|
+
fetchImpl = globalThis.fetch,
|
|
96
|
+
env = process.env,
|
|
97
|
+
internalKey = '',
|
|
98
|
+
} = {}) {
|
|
99
|
+
if (typeof fetchImpl !== 'function') {
|
|
100
|
+
throw new Error('A fetch implementation is required for Oomi model relay chat.');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const relayConfig = extractModelRelayConfig(cfg, accountId);
|
|
104
|
+
const validation = validateModelRelayConfig(relayConfig, { env, internalKey });
|
|
105
|
+
if (!validation.ok) {
|
|
106
|
+
throw new Error(validation.error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const normalizedMessages = normalizeMessages(messages);
|
|
110
|
+
if (normalizedMessages.length === 0) {
|
|
111
|
+
throw new Error('At least one model relay message is required.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timeout = setTimeout(() => controller.abort(), relayConfig.requestTimeoutMs);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const body = {
|
|
119
|
+
provider: relayConfig.provider || undefined,
|
|
120
|
+
model: relayConfig.model || undefined,
|
|
121
|
+
messages: normalizedMessages,
|
|
122
|
+
requestOrigin: toString(requestOrigin, relayConfig.requestOrigin),
|
|
123
|
+
maxOutputTokens: toPositiveInteger(maxOutputTokens, undefined),
|
|
124
|
+
temperature,
|
|
125
|
+
deviceId: relayConfig.deviceId || undefined,
|
|
126
|
+
conversationKey: toString(conversationKey) || undefined,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const response = await fetchImpl(buildRelayUrl(relayConfig.backendUrl, relayConfig.endpointPath), {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
'X-Oomi-Internal-Key': resolveInternalKey({ relayConfig, env, internalKey }),
|
|
134
|
+
'X-Oomi-User-Id': relayConfig.userId,
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify(body),
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const payload = await response.json().catch(() => ({}));
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const error = toString(payload.error, `Oomi model relay failed with status ${response.status}.`);
|
|
143
|
+
throw new Error(error);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return payload;
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/lib/openclawProfile.js
CHANGED
|
@@ -8,6 +8,15 @@ const DEFAULT_SESSION_KEY = 'agent:main:webchat:channel:oomi';
|
|
|
8
8
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
9
9
|
const DEFAULT_PLUGIN_TRUST_MODE = 'auto-discovery';
|
|
10
10
|
const DEFAULT_MODEL_AUTH_MODE = 'oomi-managed';
|
|
11
|
+
const DEFAULT_MODEL_RELAY_PROVIDER = 'openrouter';
|
|
12
|
+
const DEFAULT_MODEL_RELAY_MODEL = 'google/gemini-2.5-flash';
|
|
13
|
+
const DEFAULT_MODEL_RELAY_ENDPOINT_PATH = '/v1/model/relay/chat';
|
|
14
|
+
const DEFAULT_MODEL_RELAY_OPENAI_BASE_PATH = '/v1/model/relay/openai';
|
|
15
|
+
const DEFAULT_MODEL_RELAY_REQUEST_ORIGIN = 'hosted_openclaw';
|
|
16
|
+
const DEFAULT_MODEL_RELAY_TIMEOUT_MS = 60000;
|
|
17
|
+
const DEFAULT_MODEL_RELAY_INTERNAL_KEY_ENV = 'OOMI_HOSTED_BOOTSTRAP_INTERNAL_KEY';
|
|
18
|
+
const DEFAULT_MODEL_RELAY_OPENCLAW_PROVIDER_ID = 'oomi-managed';
|
|
19
|
+
const DEFAULT_MODEL_RELAY_MAX_TOKENS = 1024;
|
|
11
20
|
|
|
12
21
|
function trimString(value) {
|
|
13
22
|
return typeof value === 'string' ? value.trim() : '';
|
|
@@ -62,6 +71,14 @@ function normalizeModelAuthMode(value) {
|
|
|
62
71
|
return trimString(value) === 'provider-env' ? 'provider-env' : DEFAULT_MODEL_AUTH_MODE;
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
function normalizeProviderId(value) {
|
|
75
|
+
const normalized = trimString(value)
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
78
|
+
.replace(/^-+|-+$/g, '');
|
|
79
|
+
return normalized || DEFAULT_MODEL_RELAY_OPENCLAW_PROVIDER_ID;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
function resolveConfigPath(openclawHome, explicitPath = '') {
|
|
66
83
|
const targetPath = trimString(explicitPath);
|
|
67
84
|
if (targetPath) return path.resolve(targetPath);
|
|
@@ -129,7 +146,7 @@ function applyOomiChannelConfig(config, oomiChannel) {
|
|
|
129
146
|
const accounts = typeof oomi.accounts === 'object' && oomi.accounts ? { ...oomi.accounts } : {};
|
|
130
147
|
const defaultAccount = typeof accounts.default === 'object' && accounts.default ? { ...accounts.default } : {};
|
|
131
148
|
|
|
132
|
-
|
|
149
|
+
const nextDefaultAccount = {
|
|
133
150
|
enabled: true,
|
|
134
151
|
requestTimeoutMs: normalizePositiveInteger(oomiChannel?.requestTimeoutMs, 15000),
|
|
135
152
|
...defaultAccount,
|
|
@@ -138,6 +155,16 @@ function applyOomiChannelConfig(config, oomiChannel) {
|
|
|
138
155
|
defaultSessionKey: trimString(oomiChannel?.defaultSessionKey) || DEFAULT_SESSION_KEY,
|
|
139
156
|
};
|
|
140
157
|
|
|
158
|
+
const modelRelay = buildModelRelayConfig(oomiChannel?.modelRelay, {
|
|
159
|
+
backendUrl: nextDefaultAccount.backendUrl,
|
|
160
|
+
});
|
|
161
|
+
if (modelRelay.enabled) {
|
|
162
|
+
nextDefaultAccount.modelRelay = modelRelay;
|
|
163
|
+
} else if (Object.prototype.hasOwnProperty.call(nextDefaultAccount, 'modelRelay')) {
|
|
164
|
+
delete nextDefaultAccount.modelRelay;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
accounts.default = nextDefaultAccount;
|
|
141
168
|
oomi.defaultAccountId = 'default';
|
|
142
169
|
oomi.accounts = accounts;
|
|
143
170
|
channels.oomi = oomi;
|
|
@@ -145,6 +172,110 @@ function applyOomiChannelConfig(config, oomiChannel) {
|
|
|
145
172
|
return nextConfig;
|
|
146
173
|
}
|
|
147
174
|
|
|
175
|
+
function buildModelRelayConfig(modelRelay, defaults = {}) {
|
|
176
|
+
const relay = modelRelay && typeof modelRelay === 'object' ? modelRelay : {};
|
|
177
|
+
const enabled = normalizeBoolean(relay.enabled, false);
|
|
178
|
+
const backendUrl = trimString(relay.backendUrl) || trimString(defaults.backendUrl);
|
|
179
|
+
const openclawProviderId = normalizeProviderId(relay.openclawProviderId);
|
|
180
|
+
const model = trimString(relay.model) || DEFAULT_MODEL_RELAY_MODEL;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
enabled,
|
|
184
|
+
backendUrl,
|
|
185
|
+
endpointPath: trimString(relay.endpointPath) || DEFAULT_MODEL_RELAY_ENDPOINT_PATH,
|
|
186
|
+
openaiBasePath: trimString(relay.openaiBasePath) || DEFAULT_MODEL_RELAY_OPENAI_BASE_PATH,
|
|
187
|
+
openclawProviderId,
|
|
188
|
+
openclawModelRef: `${openclawProviderId}/${model}`,
|
|
189
|
+
provider: trimString(relay.provider) || DEFAULT_MODEL_RELAY_PROVIDER,
|
|
190
|
+
model,
|
|
191
|
+
requestOrigin: trimString(relay.requestOrigin) || DEFAULT_MODEL_RELAY_REQUEST_ORIGIN,
|
|
192
|
+
userId: trimString(relay.userId),
|
|
193
|
+
deviceId: trimString(relay.deviceId),
|
|
194
|
+
internalKeyEnv: trimString(relay.internalKeyEnv) || DEFAULT_MODEL_RELAY_INTERNAL_KEY_ENV,
|
|
195
|
+
requestTimeoutMs: normalizePositiveInteger(relay.requestTimeoutMs, DEFAULT_MODEL_RELAY_TIMEOUT_MS),
|
|
196
|
+
maxTokens: normalizePositiveInteger(relay.maxTokens, DEFAULT_MODEL_RELAY_MAX_TOKENS),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function joinUrl(baseUrl, pathValue) {
|
|
201
|
+
const base = trimString(baseUrl).replace(/\/+$/, '');
|
|
202
|
+
const suffix = `/${trimString(pathValue).replace(/^\/+/, '')}`;
|
|
203
|
+
return `${base}${suffix}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function applyOomiManagedModelProviderConfig(config, modelRelay) {
|
|
207
|
+
if (!modelRelay?.enabled || !trimString(modelRelay.backendUrl) || !trimString(modelRelay.userId)) {
|
|
208
|
+
return config;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const nextConfig = { ...(config || {}) };
|
|
212
|
+
const modelsRoot = nextConfig.models && typeof nextConfig.models === 'object' ? { ...nextConfig.models } : {};
|
|
213
|
+
const providers =
|
|
214
|
+
modelsRoot.providers && typeof modelsRoot.providers === 'object' ? { ...modelsRoot.providers } : {};
|
|
215
|
+
const providerId = normalizeProviderId(modelRelay.openclawProviderId);
|
|
216
|
+
const modelId = trimString(modelRelay.model) || DEFAULT_MODEL_RELAY_MODEL;
|
|
217
|
+
|
|
218
|
+
modelsRoot.mode = trimString(modelsRoot.mode) || 'merge';
|
|
219
|
+
providers[providerId] = {
|
|
220
|
+
...(providers[providerId] && typeof providers[providerId] === 'object' ? providers[providerId] : {}),
|
|
221
|
+
baseUrl: joinUrl(modelRelay.backendUrl, modelRelay.openaiBasePath),
|
|
222
|
+
api: 'openai-completions',
|
|
223
|
+
apiKey: `\${${modelRelay.internalKeyEnv}}`,
|
|
224
|
+
authHeader: true,
|
|
225
|
+
headers: {
|
|
226
|
+
'X-Oomi-User-Id': modelRelay.userId,
|
|
227
|
+
'X-Oomi-Device-Id': modelRelay.deviceId,
|
|
228
|
+
'X-Oomi-Relay-Provider': modelRelay.provider,
|
|
229
|
+
'X-Oomi-Request-Origin': modelRelay.requestOrigin,
|
|
230
|
+
},
|
|
231
|
+
models: [
|
|
232
|
+
{
|
|
233
|
+
id: modelId,
|
|
234
|
+
name: `Oomi Managed ${modelId}`,
|
|
235
|
+
reasoning: false,
|
|
236
|
+
input: ['text'],
|
|
237
|
+
cost: {
|
|
238
|
+
input: 0.0003,
|
|
239
|
+
output: 0.0025,
|
|
240
|
+
cacheRead: 0,
|
|
241
|
+
cacheWrite: 0,
|
|
242
|
+
},
|
|
243
|
+
contextWindow: 128000,
|
|
244
|
+
contextTokens: 96000,
|
|
245
|
+
maxTokens: normalizePositiveInteger(modelRelay.maxTokens, DEFAULT_MODEL_RELAY_MAX_TOKENS),
|
|
246
|
+
compat: {
|
|
247
|
+
requiresStringContent: true,
|
|
248
|
+
strictMessageKeys: true,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
modelsRoot.providers = providers;
|
|
254
|
+
nextConfig.models = modelsRoot;
|
|
255
|
+
|
|
256
|
+
const agents = nextConfig.agents && typeof nextConfig.agents === 'object' ? { ...nextConfig.agents } : {};
|
|
257
|
+
const defaults = agents.defaults && typeof agents.defaults === 'object' ? { ...agents.defaults } : {};
|
|
258
|
+
const defaultModel =
|
|
259
|
+
defaults.model && typeof defaults.model === 'object' && !Array.isArray(defaults.model)
|
|
260
|
+
? { ...defaults.model }
|
|
261
|
+
: {};
|
|
262
|
+
const allowedModels =
|
|
263
|
+
defaults.models && typeof defaults.models === 'object' ? { ...defaults.models } : {};
|
|
264
|
+
const modelRef = `${providerId}/${modelId}`;
|
|
265
|
+
|
|
266
|
+
defaultModel.primary = modelRef;
|
|
267
|
+
allowedModels[modelRef] = {
|
|
268
|
+
...(allowedModels[modelRef] && typeof allowedModels[modelRef] === 'object' ? allowedModels[modelRef] : {}),
|
|
269
|
+
alias: 'Oomi Managed',
|
|
270
|
+
};
|
|
271
|
+
defaults.model = defaultModel;
|
|
272
|
+
defaults.models = allowedModels;
|
|
273
|
+
agents.defaults = defaults;
|
|
274
|
+
nextConfig.agents = agents;
|
|
275
|
+
|
|
276
|
+
return nextConfig;
|
|
277
|
+
}
|
|
278
|
+
|
|
148
279
|
function removeOomiChannelConfig(config) {
|
|
149
280
|
const nextConfig = { ...(config || {}) };
|
|
150
281
|
const channels =
|
|
@@ -193,6 +324,26 @@ export function buildOomiDevLocalProfile(options = {}) {
|
|
|
193
324
|
defaultSessionKey: trimString(options.defaultSessionKey) || DEFAULT_SESSION_KEY,
|
|
194
325
|
requestTimeoutMs: normalizePositiveInteger(options.requestTimeoutMs, 15000),
|
|
195
326
|
pluginTrustMode,
|
|
327
|
+
modelRelay: buildModelRelayConfig(
|
|
328
|
+
{
|
|
329
|
+
enabled: normalizeBoolean(options.modelRelayEnabled, false),
|
|
330
|
+
backendUrl: options.modelRelayBackendUrl || options.backendUrl,
|
|
331
|
+
endpointPath: options.modelRelayEndpointPath,
|
|
332
|
+
openaiBasePath: options.modelRelayOpenaiBasePath,
|
|
333
|
+
openclawProviderId: options.modelRelayOpenclawProviderId,
|
|
334
|
+
provider: options.modelRelayProvider,
|
|
335
|
+
model: options.modelRelayModel,
|
|
336
|
+
requestOrigin: options.modelRelayRequestOrigin,
|
|
337
|
+
userId: options.modelRelayUserId,
|
|
338
|
+
deviceId: options.modelRelayDeviceId || options.deviceId,
|
|
339
|
+
internalKeyEnv: options.modelRelayInternalKeyEnv,
|
|
340
|
+
requestTimeoutMs: options.modelRelayRequestTimeoutMs,
|
|
341
|
+
maxTokens: options.modelRelayMaxTokens,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
backendUrl: options.backendUrl,
|
|
345
|
+
}
|
|
346
|
+
),
|
|
196
347
|
},
|
|
197
348
|
model: {
|
|
198
349
|
preset: trimString(options.modelPreset || 'openrouter-free'),
|
|
@@ -240,6 +391,9 @@ export function applyOpenclawProfile({
|
|
|
240
391
|
|
|
241
392
|
if (normalizeBoolean(profile?.oomiChannel?.enabled, false)) {
|
|
242
393
|
nextConfig = applyOomiChannelConfig(nextConfig, profile.oomiChannel);
|
|
394
|
+
if (normalizeModelAuthMode(profile?.model?.authMode) === 'oomi-managed') {
|
|
395
|
+
nextConfig = applyOomiManagedModelProviderConfig(nextConfig, profile.oomiChannel.modelRelay);
|
|
396
|
+
}
|
|
243
397
|
} else {
|
|
244
398
|
nextConfig = removeOomiChannelConfig(nextConfig);
|
|
245
399
|
}
|
package/lib/personaApiClient.js
CHANGED
|
@@ -149,6 +149,15 @@ export function createPersonaApiClient({
|
|
|
149
149
|
});
|
|
150
150
|
},
|
|
151
151
|
|
|
152
|
+
getFinanceContext() {
|
|
153
|
+
return getJson({
|
|
154
|
+
fetchImpl,
|
|
155
|
+
backendUrl: resolvedBackendUrl,
|
|
156
|
+
deviceToken: resolvedDeviceToken,
|
|
157
|
+
path: '/v1/context/finance',
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
|
|
152
161
|
requestHealthContextSync({
|
|
153
162
|
reason = 'agent_requested',
|
|
154
163
|
} = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oomi-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Managed Oomi channel, bridge, voice, and persona app API tooling for OpenClaw",
|
|
5
5
|
"bin": {
|
|
6
6
|
"oomi": "bin/oomi-ai.js"
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"files": [
|
|
60
60
|
"bin/oomi-ai.js",
|
|
61
61
|
"bin/sessionBridgeState.js",
|
|
62
|
+
"lib/modelRelayClient.js",
|
|
62
63
|
"lib/openclawPaths.js",
|
|
63
64
|
"lib/openclawProfile.js",
|
|
64
65
|
"lib/personaApiClient.js",
|
package/persona-app/README.md
CHANGED
|
@@ -19,14 +19,11 @@ The current files are the first shared contract for the vertical slice:
|
|
|
19
19
|
OpenClaw agents should use the packaged CLI to inspect and update account-linked persona apps:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
oomi fitness show --json
|
|
23
22
|
oomi persona-apps list --json
|
|
24
23
|
oomi persona-apps show fitness-today --json
|
|
25
24
|
oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
|
|
26
25
|
```
|
|
27
26
|
|
|
28
|
-
For Fitness, prefer `oomi fitness show --json` before answering because it returns the mini-app state and approved health context together.
|
|
29
|
-
|
|
30
27
|
When answering data-backed health or fitness questions, agents should read approved backend context first:
|
|
31
28
|
|
|
32
29
|
```bash
|
|
@@ -42,3 +39,5 @@ oomi context health sync --wait --json
|
|
|
42
39
|
Answer only after fresh context is uploaded. If the command times out, tell the user the phone sync is still pending.
|
|
43
40
|
|
|
44
41
|
If a persona app is missing, ask the user to add it from the Oomi client first so platform-specific permissions and hydration can run.
|
|
42
|
+
|
|
43
|
+
Finance balances are a signed-in web persona-app. `oomi context finance` / `finance.context.read` stay off the connected shell until Gate A.
|
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "persona-app.v1",
|
|
3
|
-
"registryVersion": "2026-04-
|
|
3
|
+
"registryVersion": "2026-04-24.1",
|
|
4
4
|
"contextTools": [
|
|
5
5
|
{
|
|
6
6
|
"id": "health.context.read",
|
|
7
7
|
"command": "oomi context health --json",
|
|
8
8
|
"endpoint": "/v1/context/health",
|
|
9
9
|
"description": "Reads the latest account-linked health context synced by Oomi mobile clients.",
|
|
10
|
-
"permissions": [
|
|
10
|
+
"permissions": [
|
|
11
|
+
"healthkit.read",
|
|
12
|
+
"health_connect.read"
|
|
13
|
+
],
|
|
11
14
|
"readOnly": true,
|
|
12
|
-
"targets": [
|
|
15
|
+
"targets": [
|
|
16
|
+
"ios",
|
|
17
|
+
"android",
|
|
18
|
+
"web"
|
|
19
|
+
]
|
|
13
20
|
}
|
|
14
21
|
],
|
|
15
22
|
"sharedComponents": [
|
|
16
23
|
{
|
|
17
24
|
"type": "shared.personaHero",
|
|
18
25
|
"description": "Domain hero with title, subtitle, and a primary metric.",
|
|
19
|
-
"targets": [
|
|
26
|
+
"targets": [
|
|
27
|
+
"ios",
|
|
28
|
+
"android",
|
|
29
|
+
"web",
|
|
30
|
+
"webspatial"
|
|
31
|
+
]
|
|
20
32
|
},
|
|
21
33
|
{
|
|
22
34
|
"type": "shared.oomiNote",
|
|
23
35
|
"description": "A primary-persona note with optional actions.",
|
|
24
|
-
"targets": [
|
|
36
|
+
"targets": [
|
|
37
|
+
"ios",
|
|
38
|
+
"android",
|
|
39
|
+
"web",
|
|
40
|
+
"webspatial"
|
|
41
|
+
]
|
|
25
42
|
}
|
|
26
43
|
],
|
|
27
44
|
"personaPacks": [
|
|
@@ -32,37 +49,38 @@
|
|
|
32
49
|
{
|
|
33
50
|
"type": "fitness.todaySummary",
|
|
34
51
|
"description": "Daily movement summary with progress, steps, and sleep context.",
|
|
35
|
-
"targets": [
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"targets": ["ios", "android", "web"]
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"type": "fitness.dailyPlanCard",
|
|
44
|
-
"description": "Goal-aware daily plan generated from approved health context.",
|
|
45
|
-
"targets": ["ios", "android", "web"]
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
"type": "fitness.goalEditorCard",
|
|
49
|
-
"description": "Lets the user select one active fitness focus from approved Oomi goals.",
|
|
50
|
-
"targets": ["ios", "android", "web"]
|
|
52
|
+
"targets": [
|
|
53
|
+
"ios",
|
|
54
|
+
"android",
|
|
55
|
+
"web"
|
|
56
|
+
]
|
|
51
57
|
},
|
|
52
58
|
{
|
|
53
59
|
"type": "fitness.goalChecklist",
|
|
54
60
|
"description": "Checklist of suggested goals for today.",
|
|
55
|
-
"targets": [
|
|
61
|
+
"targets": [
|
|
62
|
+
"ios",
|
|
63
|
+
"android",
|
|
64
|
+
"web"
|
|
65
|
+
]
|
|
56
66
|
},
|
|
57
67
|
{
|
|
58
68
|
"type": "fitness.metricGrid",
|
|
59
69
|
"description": "Grid of approved HealthKit or Health Connect activity, sleep, cardio, and body metrics.",
|
|
60
|
-
"targets": [
|
|
70
|
+
"targets": [
|
|
71
|
+
"ios",
|
|
72
|
+
"android",
|
|
73
|
+
"web"
|
|
74
|
+
]
|
|
61
75
|
},
|
|
62
76
|
{
|
|
63
77
|
"type": "fitness.workoutList",
|
|
64
78
|
"description": "Recent workouts synced from the user's approved mobile health source.",
|
|
65
|
-
"targets": [
|
|
79
|
+
"targets": [
|
|
80
|
+
"ios",
|
|
81
|
+
"android",
|
|
82
|
+
"web"
|
|
83
|
+
]
|
|
66
84
|
}
|
|
67
85
|
],
|
|
68
86
|
"actions": [
|
|
@@ -71,9 +89,13 @@
|
|
|
71
89
|
"description": "Marks one fitness goal complete.",
|
|
72
90
|
"payloadSchema": {
|
|
73
91
|
"type": "object",
|
|
74
|
-
"required": [
|
|
92
|
+
"required": [
|
|
93
|
+
"goalId"
|
|
94
|
+
],
|
|
75
95
|
"properties": {
|
|
76
|
-
"goalId": {
|
|
96
|
+
"goalId": {
|
|
97
|
+
"type": "string"
|
|
98
|
+
}
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
101
|
},
|
|
@@ -82,22 +104,13 @@
|
|
|
82
104
|
"description": "Records a completed workout and updates today's movement state.",
|
|
83
105
|
"payloadSchema": {
|
|
84
106
|
"type": "object",
|
|
85
|
-
"properties": {
|
|
86
|
-
"goalId": { "type": "string" },
|
|
87
|
-
"minutes": { "type": "integer", "minimum": 1 }
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
"id": "fitness.set_goal",
|
|
93
|
-
"description": "Sets the user's active Fitness focus so Oomi can tune the mini-app and future nudges.",
|
|
94
|
-
"payloadSchema": {
|
|
95
|
-
"type": "object",
|
|
96
|
-
"required": ["goalId"],
|
|
97
107
|
"properties": {
|
|
98
108
|
"goalId": {
|
|
99
|
-
"type": "string"
|
|
100
|
-
|
|
109
|
+
"type": "string"
|
|
110
|
+
},
|
|
111
|
+
"minutes": {
|
|
112
|
+
"type": "integer",
|
|
113
|
+
"minimum": 1
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
}
|
|
@@ -109,6 +122,46 @@
|
|
|
109
122
|
"healthkit.read",
|
|
110
123
|
"health_connect.read"
|
|
111
124
|
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"personaType": "finance",
|
|
128
|
+
"displayName": "Finance",
|
|
129
|
+
"components": [
|
|
130
|
+
{
|
|
131
|
+
"type": "finance.accountList",
|
|
132
|
+
"description": "Linked depository and credit accounts with current/available balances.",
|
|
133
|
+
"targets": [
|
|
134
|
+
"web"
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"type": "finance.dataSource",
|
|
139
|
+
"description": "Plaid sandbox link status for the Finance persona-app.",
|
|
140
|
+
"targets": [
|
|
141
|
+
"web"
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"actions": [
|
|
146
|
+
{
|
|
147
|
+
"id": "finance.sync_balances",
|
|
148
|
+
"description": "Refresh balances for linked depository and credit accounts.",
|
|
149
|
+
"payloadSchema": {
|
|
150
|
+
"type": "object"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"id": "finance.reconnect",
|
|
155
|
+
"description": "Refresh Finance persona-app state after a Plaid update-mode reconnect.",
|
|
156
|
+
"payloadSchema": {
|
|
157
|
+
"type": "object"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"permissions": [
|
|
162
|
+
"finance.balances.read",
|
|
163
|
+
"plaid.link"
|
|
164
|
+
]
|
|
112
165
|
}
|
|
113
166
|
]
|
|
114
167
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -11,44 +11,25 @@ Use this skill when you need to:
|
|
|
11
11
|
- inspect managed chat or voice health
|
|
12
12
|
- inspect or update component-composed Oomi persona app state
|
|
13
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)
|
|
14
15
|
- control the Oomi avatar with inline tags
|
|
15
16
|
|
|
16
17
|
## Primary Operator Workflow
|
|
17
18
|
|
|
18
19
|
If the user wants OpenClaw connected to Oomi:
|
|
19
20
|
|
|
20
|
-
1.
|
|
21
|
+
1. For a first-time install or missing local pairing state, run the bootstrap exactly once using the requested app URL:
|
|
21
22
|
```bash
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
Fallback:
|
|
25
|
-
```bash
|
|
26
|
-
npm install -g oomi-ai@latest
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
2. Pair the device:
|
|
30
|
-
```bash
|
|
31
|
-
oomi openclaw pair --app-url https://www.oomi.ai --no-start
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
3. Install the plugin:
|
|
35
|
-
```bash
|
|
36
|
-
openclaw plugins install oomi-ai@latest
|
|
23
|
+
curl -fsSL https://www.oomi.ai/install.sh | bash
|
|
37
24
|
```
|
|
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.
|
|
38
26
|
|
|
39
|
-
|
|
27
|
+
2. Print config guidance and apply the `channels.oomi.accounts.default` config:
|
|
40
28
|
```bash
|
|
41
29
|
oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai
|
|
42
30
|
```
|
|
43
31
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
6. Refresh the running bridge after package updates:
|
|
47
|
-
```bash
|
|
48
|
-
oomi openclaw refresh --skip-version-check
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
7. Start or repair the bridge:
|
|
32
|
+
3. Restart OpenClaw, then ensure the saved bridge is running:
|
|
52
33
|
```bash
|
|
53
34
|
oomi openclaw bridge ensure --detach
|
|
54
35
|
```
|
|
@@ -61,6 +42,20 @@ On macOS, prefer supervised mode:
|
|
|
61
42
|
oomi openclaw bridge service install
|
|
62
43
|
```
|
|
63
44
|
|
|
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
|
|
48
|
+
```
|
|
49
|
+
|
|
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:
|
|
51
|
+
```bash
|
|
52
|
+
pnpm add -g oomi-ai@latest
|
|
53
|
+
openclaw plugins install oomi-ai@latest
|
|
54
|
+
oomi openclaw refresh --skip-version-check
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If the user provides a specific app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
|
|
58
|
+
|
|
64
59
|
## Health Checks
|
|
65
60
|
|
|
66
61
|
Use these when chat or voice is failing:
|
|
@@ -101,25 +96,18 @@ Interpret bridge states like this:
|
|
|
101
96
|
Component-composed persona apps are the default Oomi mini-app path. Use these commands when the user asks about or updates an existing native/web Oomi persona app such as Fitness Today:
|
|
102
97
|
|
|
103
98
|
```bash
|
|
104
|
-
oomi fitness show --json
|
|
105
99
|
oomi persona-apps list --json
|
|
106
100
|
oomi persona-apps show fitness-today --json
|
|
107
101
|
oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
|
|
108
102
|
```
|
|
109
103
|
|
|
110
104
|
Rules:
|
|
111
|
-
- prefer `oomi fitness show --json` for Fitness questions because it combines mini-app state and approved health context
|
|
112
105
|
- answer from returned `appState.bindings`, not from memory
|
|
113
106
|
- apply updates through approved `persona-apps apply-action` actions
|
|
114
107
|
- if the persona app does not exist, ask the user to add it from the Oomi client first so permissions and hydration can run
|
|
115
108
|
- do not create local persona UI projects from the OpenClaw machine
|
|
116
109
|
- do not execute persona UI jobs or start local persona app runtimes
|
|
117
110
|
|
|
118
|
-
Supported Fitness actions include:
|
|
119
|
-
- `fitness.set_goal` with `{"goalId":"move_more"}`, `{"goalId":"sleep_better"}`, or `{"goalId":"build_workout_habit"}`
|
|
120
|
-
- `fitness.complete_goal`
|
|
121
|
-
- `fitness.complete_workout`
|
|
122
|
-
|
|
123
111
|
## Permissioned Context Tools
|
|
124
112
|
|
|
125
113
|
Use context commands before answering personal data questions that should come from Oomi-approved permissions.
|
|
@@ -199,3 +187,7 @@ Supported inline tags include:
|
|
|
199
187
|
- expressions: `[face:happy]`, `[face:sad]`, `[face:surprised]`, `[face:focused]`, `[face:gentle]`, `[face:thinking]`
|
|
200
188
|
- gestures: `[gesture:nod]`, `[gesture:think]`, `[gesture:shrug]`, `[gesture:wave]`, `[gesture:bow]`
|
|
201
189
|
- gaze: `[look:camera]`, `[look:left]`, `[look:right]`, `[look:up]`, `[look:down]`
|
|
190
|
+
|
|
191
|
+
## Permissioned Finance Context
|
|
192
|
+
|
|
193
|
+
Finance is a specialist persona-app on signed-in web. It is not a connected-chat tool. Do not run `oomi context finance`. There is no `finance.context.read` contextTool until Gate A. If the user asks about bank balances in live chat, send them to the web Finance persona-app. Do not answer from bank data.
|
|
@@ -15,26 +15,23 @@ Aliases allowed (if needed): wave -> Waving, walk -> Walking, idle -> Idle, sit/
|
|
|
15
15
|
|
|
16
16
|
If the user asks to connect OpenClaw to Oomi voice or chat:
|
|
17
17
|
|
|
18
|
-
1.
|
|
18
|
+
1. For a first-time install or missing local pairing state, run bootstrap exactly once using the requested app URL:
|
|
19
19
|
- `curl -fsSL https://www.oomi.ai/install.sh | bash`
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
- `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
|
|
24
|
-
4. Install OpenClaw plugin:
|
|
25
|
-
- `openclaw plugins install oomi-ai@latest`
|
|
26
|
-
5. Configure channel account (`channels.oomi.accounts.default`) from:
|
|
20
|
+
- Bootstrap installs or updates `oomi-ai`, performs the one initial pair, installs the plugin, and starts the saved bridge detached.
|
|
21
|
+
- Do not pair again after bootstrap.
|
|
22
|
+
2. Configure channel account (`channels.oomi.accounts.default`) from:
|
|
27
23
|
- `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
3. Restart OpenClaw.
|
|
25
|
+
4. Ensure exactly one bridge worker is running (singleton handler):
|
|
30
26
|
- `oomi openclaw refresh --skip-version-check`
|
|
31
27
|
- `oomi openclaw bridge ensure --detach`
|
|
32
28
|
- If stale/disconnected: `oomi openclaw bridge restart --detach`
|
|
33
29
|
- On macOS, prefer supervised mode: `oomi openclaw bridge service install`
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
5. If the Auth Link printed by bootstrap is missing or expired, create a replacement without re-pairing:
|
|
31
|
+
- `oomi openclaw invite --app-url https://www.oomi.ai`
|
|
32
|
+
6. If the user provides an app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
|
|
33
|
+
7. If the machine is already paired, do not rerun bootstrap. Use `invite` for link recovery and `bridge ensure` or `bridge restart` for runtime recovery.
|
|
34
|
+
8. Return this exact result format to the user:
|
|
38
35
|
- `Oomi Connect Ready`
|
|
39
36
|
- `Auth Link: ...`
|
|
40
37
|
- No extra narrative text before or after those lines.
|
|
@@ -87,24 +84,17 @@ Oomi persona apps are component-composed client surfaces backed by approved temp
|
|
|
87
84
|
Persona UI creation and rendering happen inside Oomi-managed systems. Use the Oomi API tools instead:
|
|
88
85
|
|
|
89
86
|
```bash
|
|
90
|
-
oomi fitness show --json
|
|
91
87
|
oomi persona-apps list --json
|
|
92
88
|
oomi persona-apps show fitness-today --json
|
|
93
89
|
oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
|
|
94
90
|
```
|
|
95
91
|
|
|
96
92
|
Rules:
|
|
97
|
-
- for Fitness questions, prefer `oomi fitness show --json` first because it combines mini-app state and approved health context
|
|
98
93
|
- answer from returned `appState.bindings`, not memory
|
|
99
94
|
- apply updates only through approved `persona-apps apply-action` actions
|
|
100
95
|
- if the persona app is missing, ask the user to add it from the Oomi client first so permissions and hydration can run
|
|
101
96
|
- do not execute persona UI jobs or start local persona app runtimes
|
|
102
97
|
|
|
103
|
-
Supported Fitness actions include:
|
|
104
|
-
- `fitness.set_goal` with `{"goalId":"move_more"}`, `{"goalId":"sleep_better"}`, or `{"goalId":"build_workout_habit"}`
|
|
105
|
-
- `fitness.complete_goal`
|
|
106
|
-
- `fitness.complete_workout`
|
|
107
|
-
|
|
108
98
|
## Permissioned Context Tools
|
|
109
99
|
|
|
110
100
|
Use Oomi context tools when the user asks about personal data that should come from mobile permissions instead of memory.
|