oomi-ai 0.3.4 → 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 CHANGED
@@ -145,6 +145,35 @@ 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:
@@ -19,27 +19,24 @@ Get OpenClaw into a state where:
19
19
 
20
20
  If the user asks to connect OpenClaw to Oomi chat or voice:
21
21
 
22
- 1. Refresh bootstrap first:
22
+ 1. For a first-time install or missing local pairing state, run the bootstrap exactly once using the requested app URL:
23
23
  - `curl -fsSL https://www.oomi.ai/install.sh | bash`
24
- 2. Ensure `oomi-ai` is installed:
25
- - `pnpm add -g oomi-ai@latest`
26
- - fallback: `npm install -g oomi-ai@latest`
27
- 3. Pair the device without starting a legacy one-off flow:
28
- - `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
29
- 4. Install the OpenClaw plugin:
30
- - `openclaw plugins install oomi-ai@latest`
31
- 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:
32
27
  - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
33
- 6. Apply the printed `channels.oomi.accounts.default` config.
34
- 7. Restart OpenClaw.
35
- 8. Start or repair the bridge:
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:
36
31
  - `oomi openclaw bridge ensure --detach`
37
32
  - if stale: `oomi openclaw bridge restart --detach`
38
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`
39
36
 
40
- If the user provides a specific app URL, use it directly in the pair flow:
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.
41
38
 
42
- - `oomi openclaw pair --app-url <URL> --no-start`
39
+ If the user provides a specific app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
43
40
 
44
41
  ## Required User-Facing Result
45
42
 
package/bin/oomi-ai.js CHANGED
@@ -379,18 +379,6 @@ function findRepoRoot(startDir) {
379
379
  return null;
380
380
  }
381
381
 
382
- function resolveRepoRoot(rootFlag) {
383
- const explicitRoot =
384
- typeof rootFlag === 'string' && rootFlag.trim()
385
- ? path.resolve(rootFlag.trim())
386
- : '';
387
- const repoRoot = explicitRoot || findRepoRoot(process.cwd()) || findRepoRoot(PACKAGE_ROOT);
388
- if (!repoRoot) {
389
- throw new Error('Could not locate repo root. Use --root <repo root>.');
390
- }
391
- return repoRoot;
392
- }
393
-
394
382
  function resolveSkillSource(cliRoot) {
395
383
  const packaged = path.join(PACKAGE_ROOT, 'skills', 'oomi');
396
384
  if (fs.existsSync(packaged)) {
@@ -584,6 +572,10 @@ async function handleContextHealthCommand(flags = {}) {
584
572
  printStructuredResult(payload, isTruthyFlag(flags.json));
585
573
  }
586
574
 
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.");
577
+ }
578
+
587
579
  async function handleContextHealthSyncCommand(flags = {}) {
588
580
  const client = createCliPersonaApiClient(flags);
589
581
  const payload = await client.requestHealthContextSync({
@@ -741,6 +733,28 @@ function readOpenclawGatewayConfig() {
741
733
  };
742
734
  }
743
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
+
744
758
  function resolveBridgeStatePath() {
745
759
  return resolveOpenclawBridgeStatePath();
746
760
  }
@@ -1049,6 +1063,25 @@ async function requestManagedPairCode({ appUrl, label }) {
1049
1063
  return payload;
1050
1064
  }
1051
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
+
1052
1085
  async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
1053
1086
  const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
1054
1087
  method: 'POST',
@@ -1084,6 +1117,35 @@ async function fetchManagedGatewayConfig({ appUrl }) {
1084
1117
  return payload;
1085
1118
  }
1086
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
+
1087
1149
  function base64UrlEncode(value) {
1088
1150
  return Buffer.from(value)
1089
1151
  .toString('base64')
@@ -1742,48 +1804,6 @@ function printAssistantFinalDebugResult(result, asJson) {
1742
1804
  }
1743
1805
  }
1744
1806
 
1745
- function resolveCommandFromPath(commandName) {
1746
- const normalized = String(commandName || '').trim();
1747
- if (!normalized) return '';
1748
- try {
1749
- const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [normalized], {
1750
- encoding: 'utf8',
1751
- stdio: ['ignore', 'pipe', 'ignore'],
1752
- });
1753
- if (probe.status !== 0) return '';
1754
- const firstLine = String(probe.stdout || '')
1755
- .split(/\r?\n/)
1756
- .map((line) => line.trim())
1757
- .find(Boolean);
1758
- return firstLine || '';
1759
- } catch {
1760
- return '';
1761
- }
1762
- }
1763
-
1764
- function resolveExecutable(candidates = []) {
1765
- for (const candidate of candidates) {
1766
- if (!candidate) continue;
1767
- const value = String(candidate).trim();
1768
- if (!value) continue;
1769
- if (path.isAbsolute(value) && fs.existsSync(value)) {
1770
- return value;
1771
- }
1772
- if (value.includes(path.sep) || value.includes('/')) {
1773
- const resolved = path.resolve(value);
1774
- if (fs.existsSync(resolved)) {
1775
- return resolved;
1776
- }
1777
- continue;
1778
- }
1779
- const fromPath = resolveCommandFromPath(value);
1780
- if (fromPath) {
1781
- return fromPath;
1782
- }
1783
- }
1784
- return '';
1785
- }
1786
-
1787
1807
  async function handleOpenclawDebugCommand(action, flags) {
1788
1808
  const normalizedAction = String(action || '').trim().toLowerCase();
1789
1809
  const frameFile =
@@ -2527,11 +2547,13 @@ async function startOpenclawBridge(flags) {
2527
2547
  if (!deviceToken) {
2528
2548
  throw new Error('Broker pair claim did not return deviceToken.');
2529
2549
  }
2550
+ const configSessionKey = readOpenclawDefaultSessionKey();
2530
2551
  writeBridgeState({
2531
2552
  brokerHttp,
2532
2553
  brokerWs,
2533
2554
  deviceId,
2534
2555
  deviceToken,
2556
+ sessionKey: configSessionKey || 'agent:main:webchat:channel:oomi',
2535
2557
  claimedAt: new Date().toISOString(),
2536
2558
  expiresAt: claimed.expiresAt || null,
2537
2559
  });
@@ -3806,7 +3828,24 @@ async function pairAndStartOpenclawBridge(flags) {
3806
3828
 
3807
3829
  console.log(`Pairing OpenClaw host with Oomi app: ${appUrl}`);
3808
3830
  const managedConfig = await fetchManagedGatewayConfig({ appUrl });
3809
- const pairStarted = await requestManagedPairCode({ appUrl, label });
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
+ }
3810
3849
  const pairCode = String(pairStarted.pairCode || '').trim().toUpperCase();
3811
3850
  if (!pairCode) {
3812
3851
  throw new Error('Pairing code missing from backend response.');
@@ -3814,7 +3853,7 @@ async function pairAndStartOpenclawBridge(flags) {
3814
3853
 
3815
3854
  console.log(`Received pair code: ${pairCode}`);
3816
3855
  const claimed = await claimBridgeDeviceToken({
3817
- brokerHttp: managedConfig.brokerHttpUrl,
3856
+ brokerHttp,
3818
3857
  pairCode,
3819
3858
  deviceId,
3820
3859
  });
@@ -3823,9 +3862,11 @@ async function pairAndStartOpenclawBridge(flags) {
3823
3862
  throw new Error('Broker pair claim did not return deviceToken.');
3824
3863
  }
3825
3864
 
3826
- const brokerWs = String(flags['broker-ws'] || managedConfig.brokerDeviceWsUrl).trim();
3865
+ const brokerWs = String(
3866
+ flags['broker-ws'] || rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerDeviceWsUrl, appUrl)
3867
+ ).trim();
3827
3868
  writeBridgeState({
3828
- brokerHttp: managedConfig.brokerHttpUrl,
3869
+ brokerHttp,
3829
3870
  brokerWs,
3830
3871
  deviceId,
3831
3872
  deviceToken,
@@ -3835,7 +3876,7 @@ async function pairAndStartOpenclawBridge(flags) {
3835
3876
  });
3836
3877
 
3837
3878
  const invite = await requestConnectInviteLink({
3838
- backendHttp: managedConfig.brokerHttpUrl,
3879
+ backendHttp: brokerHttp,
3839
3880
  appUrl,
3840
3881
  sessionKey,
3841
3882
  deviceToken,
@@ -3909,9 +3950,11 @@ async function createOpenclawInviteLink(flags) {
3909
3950
  ''
3910
3951
  ).trim();
3911
3952
  const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3953
+ const configSessionKey = readOpenclawDefaultSessionKey();
3912
3954
  const sessionKey = String(
3913
3955
  flags['session-key'] ||
3914
3956
  process.env.OOMI_SESSION_KEY ||
3957
+ configSessionKey ||
3915
3958
  bridgeState.sessionKey ||
3916
3959
  'agent:main:webchat:channel:oomi'
3917
3960
  ).trim();
@@ -4381,6 +4424,11 @@ async function main() {
4381
4424
  return;
4382
4425
  }
4383
4426
 
4427
+ if (command === 'context' && subcommand === 'finance') {
4428
+ await handleContextFinanceCommand(args.flags);
4429
+ return;
4430
+ }
4431
+
4384
4432
  console.error(`Unknown command: ${command} ${subcommand || ''}`.trim());
4385
4433
  usage();
4386
4434
  process.exit(1);
@@ -4421,4 +4469,5 @@ export {
4421
4469
  isGatewayRunStartedFrame,
4422
4470
  isBridgeWorkerCommand,
4423
4471
  parsePositiveInteger,
4472
+ rewriteLoopbackBrokerUrlForAppHost,
4424
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
+ }
@@ -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
- accounts.default = {
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
  }
@@ -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.4",
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",
@@ -39,3 +39,5 @@ oomi context health sync --wait --json
39
39
  Answer only after fresh context is uploaded. If the command times out, tell the user the phone sync is still pending.
40
40
 
41
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.
@@ -7,21 +7,38 @@
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": ["healthkit.read", "health_connect.read"],
10
+ "permissions": [
11
+ "healthkit.read",
12
+ "health_connect.read"
13
+ ],
11
14
  "readOnly": true,
12
- "targets": ["ios", "android", "web"]
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": ["ios", "android", "web", "webspatial"]
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": ["ios", "android", "web", "webspatial"]
36
+ "targets": [
37
+ "ios",
38
+ "android",
39
+ "web",
40
+ "webspatial"
41
+ ]
25
42
  }
26
43
  ],
27
44
  "personaPacks": [
@@ -32,22 +49,38 @@
32
49
  {
33
50
  "type": "fitness.todaySummary",
34
51
  "description": "Daily movement summary with progress, steps, and sleep context.",
35
- "targets": ["ios", "android", "web"]
52
+ "targets": [
53
+ "ios",
54
+ "android",
55
+ "web"
56
+ ]
36
57
  },
37
58
  {
38
59
  "type": "fitness.goalChecklist",
39
60
  "description": "Checklist of suggested goals for today.",
40
- "targets": ["ios", "android", "web"]
61
+ "targets": [
62
+ "ios",
63
+ "android",
64
+ "web"
65
+ ]
41
66
  },
42
67
  {
43
68
  "type": "fitness.metricGrid",
44
69
  "description": "Grid of approved HealthKit or Health Connect activity, sleep, cardio, and body metrics.",
45
- "targets": ["ios", "android", "web"]
70
+ "targets": [
71
+ "ios",
72
+ "android",
73
+ "web"
74
+ ]
46
75
  },
47
76
  {
48
77
  "type": "fitness.workoutList",
49
78
  "description": "Recent workouts synced from the user's approved mobile health source.",
50
- "targets": ["ios", "android", "web"]
79
+ "targets": [
80
+ "ios",
81
+ "android",
82
+ "web"
83
+ ]
51
84
  }
52
85
  ],
53
86
  "actions": [
@@ -56,9 +89,13 @@
56
89
  "description": "Marks one fitness goal complete.",
57
90
  "payloadSchema": {
58
91
  "type": "object",
59
- "required": ["goalId"],
92
+ "required": [
93
+ "goalId"
94
+ ],
60
95
  "properties": {
61
- "goalId": { "type": "string" }
96
+ "goalId": {
97
+ "type": "string"
98
+ }
62
99
  }
63
100
  }
64
101
  },
@@ -68,8 +105,13 @@
68
105
  "payloadSchema": {
69
106
  "type": "object",
70
107
  "properties": {
71
- "goalId": { "type": "string" },
72
- "minutes": { "type": "integer", "minimum": 1 }
108
+ "goalId": {
109
+ "type": "string"
110
+ },
111
+ "minutes": {
112
+ "type": "integer",
113
+ "minimum": 1
114
+ }
73
115
  }
74
116
  }
75
117
  }
@@ -80,6 +122,46 @@
80
122
  "healthkit.read",
81
123
  "health_connect.read"
82
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
+ ]
83
165
  }
84
166
  ]
85
167
  }
@@ -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. Ensure `oomi-ai` is installed or updated:
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
- pnpm add -g oomi-ai@latest
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
- 4. Print config guidance:
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
- 5. Apply the `channels.oomi.accounts.default` config and restart OpenClaw.
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:
@@ -192,3 +187,7 @@ Supported inline tags include:
192
187
  - expressions: `[face:happy]`, `[face:sad]`, `[face:surprised]`, `[face:focused]`, `[face:gentle]`, `[face:thinking]`
193
188
  - gestures: `[gesture:nod]`, `[gesture:think]`, `[gesture:shrug]`, `[gesture:wave]`, `[gesture:bow]`
194
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. Refresh bootstrap/update first:
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
- 2. Ensure `oomi-ai` is installed (`pnpm add -g oomi-ai@latest`).
21
- - Fallback: `npm install -g oomi-ai@latest`
22
- 3. Pair device token without starting legacy bridge:
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
- 6. Restart OpenClaw.
29
- 7. Ensure exactly one bridge worker is running (singleton handler):
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
- 8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
35
- - Use that URL directly in the pair command.
36
- - Example: `oomi openclaw pair --app-url <URL> --no-start`
37
- 9. Return this exact result format to the user:
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.