oomi-ai 0.2.0 → 0.2.2

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
@@ -14,7 +14,24 @@ npm install -g oomi-ai
14
14
 
15
15
  ## Usage
16
16
 
17
- Install agent instructions only (backwards compatible):
17
+ Install as an OpenClaw channel extension (preferred architecture):
18
+ ```
19
+ openclaw plugins install oomi-ai@latest
20
+ ```
21
+
22
+ This package now ships an OpenClaw channel plugin (`openclaw.plugin.json`) with channel id `oomi`.
23
+ Channel account config fields (`channels.oomi.accounts.<accountId>`):
24
+ - `backendUrl`
25
+ - `deviceToken`
26
+ - `defaultSessionKey` (optional, default `agent:main:webchat:channel:oomi`)
27
+ - `requestTimeoutMs` (optional)
28
+
29
+ Print plugin install/config guidance from local pair state:
30
+ ```
31
+ oomi openclaw plugin
32
+ ```
33
+
34
+ Install agent instructions only:
18
35
  ```
19
36
  oomi init
20
37
  ```
@@ -24,18 +41,12 @@ Install agent instructions + Oomi skill:
24
41
  oomi openclaw install
25
42
  ```
26
43
 
27
- Start managed gateway bridge (OpenClaw host -> Oomi broker):
44
+ Pair and provision device token from Oomi web backend:
28
45
  ```
29
- oomi openclaw bridge \
30
- --broker-http https://your-broker.modal.run \
31
- --broker-ws wss://your-broker.modal.run/ws/device \
32
- --pair-code ABCD2345
46
+ oomi openclaw pair --app-url https://your-oomi-app.vercel.app --device-id my-openclaw-mac --no-start
33
47
  ```
34
48
 
35
- Pair + bootstrap bridge automatically from Oomi web backend:
36
- ```
37
- oomi openclaw pair --app-url https://your-oomi-app.vercel.app --device-id my-openclaw-mac --detach
38
- ```
49
+ `--app-url` must be reachable from the OpenClaw host. If OpenClaw runs on a different machine/network, do not use `localhost` unless tunneled.
39
50
 
40
51
  This prints:
41
52
  - `Connect code: <device-id>`
@@ -45,6 +56,20 @@ This prints:
45
56
  - `Code: ...`
46
57
  - `Link: ...`
47
58
 
59
+ Agent-intent mapping (recommended):
60
+ - If user says `Connect yourself to Oomi. Use app URL https://www.oomi.ai.`
61
+ - Run:
62
+ - `curl -fsSL https://www.oomi.ai/install.sh | bash`
63
+ - or `pnpm add -g oomi-ai@latest` (`npm install -g oomi-ai@latest` fallback)
64
+ - `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
65
+ - `openclaw plugins install oomi-ai@latest`
66
+ - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
67
+ - Apply `channels.oomi.accounts.default` config and restart OpenClaw.
68
+
69
+ Important distinction:
70
+ - `pairCode` is one-time and used internally by the pair/bootstrap flow.
71
+ - `connect code` is the stable device id used by browser connect (`managed://<device-id>`).
72
+
48
73
  Sync personas from the repo into the backend registry:
49
74
  ```
50
75
  oomi personas sync --backend-url http://localhost:3001
@@ -60,9 +85,7 @@ Optional flags:
60
85
  oomi init --workspace /path/to/openclaw/workspace
61
86
  oomi init --agents-file /path/to/AGENTS.md
62
87
  oomi openclaw install --skills-dir /path/to/openclaw/skills
63
- oomi openclaw bridge --broker-ws wss://.../ws/device --device-token <token>
64
88
  oomi openclaw pair --app-url https://your-oomi-app.vercel.app --no-start
65
- oomi openclaw pair --app-url https://your-oomi-app.vercel.app --detach
66
89
  oomi openclaw pair --app-url https://your-oomi-app.vercel.app --json
67
90
  oomi personas sync --root /path/to/oomi
68
91
  oomi personas create creator --status active --chat-session agent:main:webchat:channel:oomi-creator
@@ -74,9 +97,19 @@ Defaults:
74
97
 
75
98
  Restart OpenClaw after running `oomi init` or `oomi openclaw install`.
76
99
 
100
+ ## Update Notifications
101
+ - `oomi` checks npm for a newer `oomi-ai` version on normal commands (cached, best-effort).
102
+ - When an update is available it prints:
103
+ - `pnpm add -g oomi-ai@latest`
104
+ - fallback: `npm install -g oomi-ai@latest`
105
+ - Optional env controls:
106
+ - `OOMI_SKIP_UPDATE_CHECK=1` disables checks
107
+ - `OOMI_UPDATE_CHECK_INTERVAL_MS=<ms>` changes check interval
108
+ - `OOMI_UPDATE_CHECK_TIMEOUT_MS=<ms>` changes network timeout
109
+
77
110
  ## Package Audit + Publish (pnpm)
78
111
  ```
79
- cd /Users/nemu/projects/oomi/packages/oomi-ai
112
+ cd <repo-root>/packages/oomi-ai
80
113
  pnpm check
81
114
  pnpm publish --dry-run --no-git-checks --access public
82
115
  pnpm publish --access public
@@ -15,10 +15,21 @@ 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/web:
17
17
 
18
- 1. Ensure `oomi-ai` is installed (`pnpm add -g oomi-ai`).
19
- 2. Run:
20
- - `oomi openclaw pair --app-url https://oomi.ai --detach`
21
- 3. Return this exact result format to the user:
18
+ 1. Refresh bootstrap/update first:
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:
27
+ - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
+ 6. Restart OpenClaw.
29
+ 7. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
30
+ - Use that URL directly in the pair command.
31
+ - Example: `oomi openclaw pair --app-url <URL> --no-start`
32
+ 8. Return this exact result format to the user:
22
33
  - `Oomi Connect Ready`
23
34
  - `Code: ...`
24
35
  - `Link: ...`
package/bin/oomi-ai.js CHANGED
@@ -10,6 +10,121 @@ const MARKER_START = '<oomi-agent-instructions>';
10
10
  const MARKER_END = '</oomi-agent-instructions>';
11
11
 
12
12
  const PACKAGE_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
13
+ const UPDATE_STATE_FILE = path.join(os.homedir(), '.openclaw', 'oomi-ai-update-check.json');
14
+ const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
15
+ const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1200;
16
+
17
+ function parsePositiveInteger(value, fallback) {
18
+ const num = Number(value);
19
+ if (!Number.isFinite(num) || num <= 0) return fallback;
20
+ return Math.floor(num);
21
+ }
22
+
23
+ function readJsonSafe(filePath) {
24
+ if (!fs.existsSync(filePath)) return null;
25
+ try {
26
+ return JSON.parse(readFile(filePath));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function writeJsonSafe(filePath, value) {
33
+ try {
34
+ ensureDir(path.dirname(filePath));
35
+ writeFile(filePath, JSON.stringify(value, null, 2) + '\n');
36
+ } catch {
37
+ // best-effort cache write
38
+ }
39
+ }
40
+
41
+ function currentPackageVersion() {
42
+ const packageJsonPath = path.join(PACKAGE_ROOT, 'package.json');
43
+ const packageJson = readJsonSafe(packageJsonPath);
44
+ const version = typeof packageJson?.version === 'string' ? packageJson.version.trim() : '';
45
+ return version;
46
+ }
47
+
48
+ function parseVersionTuple(version) {
49
+ if (typeof version !== 'string') return null;
50
+ const cleaned = version.trim().replace(/^v/i, '').split('-')[0];
51
+ const parts = cleaned.split('.');
52
+ if (parts.length < 3) return null;
53
+ const major = Number(parts[0]);
54
+ const minor = Number(parts[1]);
55
+ const patch = Number(parts[2]);
56
+ if (![major, minor, patch].every((n) => Number.isInteger(n) && n >= 0)) return null;
57
+ return [major, minor, patch];
58
+ }
59
+
60
+ function compareVersions(a, b) {
61
+ const av = parseVersionTuple(a);
62
+ const bv = parseVersionTuple(b);
63
+ if (!av || !bv) return 0;
64
+ for (let i = 0; i < 3; i += 1) {
65
+ if (av[i] < bv[i]) return -1;
66
+ if (av[i] > bv[i]) return 1;
67
+ }
68
+ return 0;
69
+ }
70
+
71
+ async function fetchLatestPublishedVersion(pkgName) {
72
+ const timeoutMs = parsePositiveInteger(
73
+ process.env.OOMI_UPDATE_CHECK_TIMEOUT_MS,
74
+ DEFAULT_UPDATE_CHECK_TIMEOUT_MS
75
+ );
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
78
+ try {
79
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, {
80
+ method: 'GET',
81
+ headers: {
82
+ Accept: 'application/json',
83
+ },
84
+ signal: controller.signal,
85
+ });
86
+ if (!response.ok) return '';
87
+ const payload = await response.json().catch(() => ({}));
88
+ const version = typeof payload?.version === 'string' ? payload.version.trim() : '';
89
+ return version;
90
+ } catch {
91
+ return '';
92
+ } finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+
97
+ async function maybeNotifyUpdate(command) {
98
+ if (isTruthyFlag(process.env.OOMI_SKIP_UPDATE_CHECK)) return;
99
+ if (!command || command === 'help' || command === '--help') return;
100
+
101
+ const currentVersion = currentPackageVersion();
102
+ if (!currentVersion) return;
103
+
104
+ const intervalMs = parsePositiveInteger(
105
+ process.env.OOMI_UPDATE_CHECK_INTERVAL_MS,
106
+ DEFAULT_UPDATE_CHECK_INTERVAL_MS
107
+ );
108
+ const now = Date.now();
109
+ const state = readJsonSafe(UPDATE_STATE_FILE) || {};
110
+ const lastCheckedAt = Number(state.lastCheckedAt || 0);
111
+ if (Number.isFinite(lastCheckedAt) && lastCheckedAt > 0 && now - lastCheckedAt < intervalMs) {
112
+ return;
113
+ }
114
+
115
+ const latestVersion = await fetchLatestPublishedVersion('oomi-ai');
116
+ writeJsonSafe(UPDATE_STATE_FILE, {
117
+ lastCheckedAt: now,
118
+ latestVersion: latestVersion || String(state.latestVersion || ''),
119
+ });
120
+ if (!latestVersion) return;
121
+
122
+ if (compareVersions(currentVersion, latestVersion) < 0) {
123
+ console.warn(`[oomi] Update available: oomi-ai ${currentVersion} -> ${latestVersion}`);
124
+ console.warn('[oomi] Update command: pnpm add -g oomi-ai@latest');
125
+ console.warn('[oomi] Fallback update command: npm install -g oomi-ai@latest');
126
+ }
127
+ }
13
128
 
14
129
  function usage() {
15
130
  console.log(`oomi <command>
@@ -27,6 +142,9 @@ Commands:
27
142
  openclaw pair
28
143
  Pair this OpenClaw host with Oomi and start bridge (single command).
29
144
 
145
+ openclaw plugin
146
+ Print OpenClaw extension install/config guidance for Oomi channel plugin.
147
+
30
148
  personas sync
31
149
  Sync personas from the repo into the Oomi backend registry.
32
150
 
@@ -38,7 +156,7 @@ Common flags:
38
156
  --workspace PATH Override OpenClaw workspace root
39
157
  --skills-dir PATH Override skills install dir
40
158
  --broker-http URL Managed broker HTTPS URL (for pair claim)
41
- --broker-ws URL Managed broker device WS URL (wss://.../ws/device)
159
+ --broker-ws URL Managed broker device WS URL (wss://.../cable)
42
160
  --pair-code CODE One-time pairing code from Oomi
43
161
  --app-url URL Oomi app URL used for pairing APIs (default: http://127.0.0.1:3000)
44
162
  --label TEXT Pairing label shown in broker logs
@@ -47,6 +165,7 @@ Common flags:
47
165
  --no-start Pair and save token, but do not start bridge
48
166
  --device-id ID Bridge device identifier (default: host name)
49
167
  --device-token TOKEN Existing bridge device token
168
+ --show-secrets Print full token values in diagnostic output
50
169
  --json Print pairing result as JSON (for automation)
51
170
  --backend-url URL Override Oomi backend URL
52
171
  --root PATH Override repo root path for persona discovery
@@ -499,6 +618,17 @@ function injectGatewayAuth(frameText, gatewayAuth) {
499
618
  return frameText;
500
619
  }
501
620
  const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
621
+ const existingScopes = Array.isArray(params.scopes)
622
+ ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
623
+ : [];
624
+ const requiredScopes = ['operator.read', 'operator.write'];
625
+ for (const scope of requiredScopes) {
626
+ if (!existingScopes.includes(scope)) {
627
+ existingScopes.push(scope);
628
+ }
629
+ }
630
+ params.scopes = existingScopes;
631
+
502
632
  const auth = {};
503
633
  if (gatewayAuth.token) auth.token = gatewayAuth.token;
504
634
  else if (gatewayAuth.password) auth.password = gatewayAuth.password;
@@ -523,19 +653,39 @@ function parseJsonPayload(raw) {
523
653
 
524
654
  async function startOpenclawBridge(flags) {
525
655
  const bridgeState = readBridgeState();
526
- const brokerHttp = String(flags['broker-http'] || process.env.OOMI_MANAGED_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
527
- const brokerWs = String(flags['broker-ws'] || process.env.OOMI_MANAGED_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
656
+ const brokerHttp = String(
657
+ flags['broker-http'] ||
658
+ process.env.OOMI_CHAT_BROKER_HTTP_URL ||
659
+ bridgeState.brokerHttp ||
660
+ ''
661
+ ).trim();
662
+ const brokerWs = String(
663
+ flags['broker-ws'] ||
664
+ process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL ||
665
+ bridgeState.brokerWs ||
666
+ ''
667
+ ).trim();
528
668
  const deviceId = resolveDeviceId(flags, bridgeState);
529
669
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
530
- let deviceToken = String(flags['device-token'] || bridgeState.deviceToken || '').trim();
670
+ const explicitDeviceToken = String(flags['device-token'] || '').trim();
671
+ const canReuseStateToken =
672
+ String(bridgeState.deviceId || '').trim() === deviceId &&
673
+ String(bridgeState.brokerWs || '').trim() === brokerWs &&
674
+ String(bridgeState.brokerHttp || '').trim() === brokerHttp;
675
+ let deviceToken = explicitDeviceToken;
676
+ if (!deviceToken && canReuseStateToken) {
677
+ deviceToken = String(bridgeState.deviceToken || '').trim();
678
+ }
531
679
 
532
680
  if (!brokerWs) {
533
- throw new Error('Missing broker device websocket URL. Set --broker-ws or OOMI_MANAGED_BROKER_DEVICE_WS_URL.');
681
+ throw new Error('Missing broker device websocket URL. Set --broker-ws or OOMI_CHAT_BROKER_DEVICE_WS_URL.');
534
682
  }
535
683
 
536
684
  if (!deviceToken) {
537
685
  if (!brokerHttp || !pairCode) {
538
- throw new Error('No saved device token. Provide --pair-code and --broker-http to claim one.');
686
+ throw new Error(
687
+ 'No valid saved device token for this device/broker. Provide --pair-code and --broker-http to claim one.'
688
+ );
539
689
  }
540
690
  const claimed = await claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId });
541
691
  deviceToken = String(claimed.deviceToken || '').trim();
@@ -563,22 +713,95 @@ async function startOpenclawBridge(flags) {
563
713
  console.log(`Broker WS: ${brokerWs}`);
564
714
 
565
715
  const activeGatewaySockets = new Map();
716
+ const brokerPath = (() => {
717
+ try {
718
+ return new URL(brokerWs).pathname || '';
719
+ } catch {
720
+ return '';
721
+ }
722
+ })();
723
+ const actionCableMode = brokerPath.endsWith('/cable');
724
+ const deviceChannelIdentifier = JSON.stringify({ channel: 'DeviceChannel' });
725
+
726
+ const sendBrokerPayload = (brokerSocket, payload) => {
727
+ if (brokerSocket.readyState !== WebSocket.OPEN) return;
728
+ if (!actionCableMode) {
729
+ brokerSocket.send(JSON.stringify(payload));
730
+ return;
731
+ }
732
+ brokerSocket.send(
733
+ JSON.stringify({
734
+ command: 'message',
735
+ identifier: deviceChannelIdentifier,
736
+ data: JSON.stringify(payload),
737
+ })
738
+ );
739
+ };
740
+
741
+ const parseBrokerEnvelope = (raw) => {
742
+ const payload = parseJsonPayload(raw);
743
+ if (!payload) return null;
744
+ if (!actionCableMode) return payload;
745
+
746
+ if (payload.type === 'welcome' || payload.type === 'ping') return null;
747
+ if (payload.type === 'confirm_subscription') return { type: 'device.subscribed' };
748
+ if (payload.type === 'disconnect') {
749
+ return {
750
+ type: 'broker.disconnect',
751
+ reason: String(payload.reason || ''),
752
+ };
753
+ }
754
+ if (payload.type === 'reject_subscription') {
755
+ return {
756
+ type: 'broker.reject_subscription',
757
+ };
758
+ }
759
+ if (payload.message && typeof payload.message === 'object') {
760
+ return payload.message;
761
+ }
762
+ return null;
763
+ };
566
764
 
567
765
  const connectBroker = () => {
568
766
  const wsUrl = new URL(brokerWs);
569
767
  wsUrl.searchParams.set('token', deviceToken);
570
768
 
571
769
  const brokerSocket = new WebSocket(wsUrl.toString());
770
+ let actionCableHeartbeat = null;
572
771
 
573
772
  brokerSocket.on('open', () => {
574
773
  console.log('[bridge] Connected to managed broker.');
774
+ if (!actionCableMode) return;
775
+ brokerSocket.send(
776
+ JSON.stringify({
777
+ command: 'subscribe',
778
+ identifier: deviceChannelIdentifier,
779
+ })
780
+ );
781
+ actionCableHeartbeat = setInterval(() => {
782
+ sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
783
+ }, 15000);
575
784
  });
576
785
 
577
786
  brokerSocket.on('message', (rawData) => {
578
787
  const text = typeof rawData === 'string' ? rawData : rawData.toString();
579
- const payload = parseJsonPayload(text);
788
+ const payload = parseBrokerEnvelope(text);
580
789
  if (!payload || typeof payload.type !== 'string') return;
581
790
 
791
+ if (payload.type === 'device.subscribed') {
792
+ return;
793
+ }
794
+
795
+ if (payload.type === 'broker.disconnect') {
796
+ console.error(`[bridge] Broker rejected connection: ${String(payload.reason || 'unauthorized')}`);
797
+ return;
798
+ }
799
+
800
+ if (payload.type === 'broker.reject_subscription') {
801
+ console.error('[bridge] Broker rejected DeviceChannel subscription.');
802
+ return;
803
+ }
804
+
582
805
  if (payload.type === 'device.ready') {
583
806
  console.log(`[bridge] Broker ready for device ${payload.deviceId || deviceId}.`);
584
807
  return;
@@ -587,27 +810,53 @@ async function startOpenclawBridge(flags) {
587
810
  if (payload.type === 'client.open') {
588
811
  const sessionId = String(payload.sessionId || '').trim();
589
812
  if (!sessionId || activeGatewaySockets.has(sessionId)) return;
813
+ console.log(`[bridge] client.open ${sessionId}`);
590
814
  const gatewaySocket = new WebSocket(gateway.gatewayUrl);
591
- activeGatewaySockets.set(sessionId, gatewaySocket);
815
+ const sessionBridge = {
816
+ socket: gatewaySocket,
817
+ queue: [],
818
+ };
819
+ activeGatewaySockets.set(sessionId, sessionBridge);
820
+
821
+ gatewaySocket.on('open', () => {
822
+ console.log(`[bridge] gateway.open ${sessionId}`);
823
+ while (sessionBridge.queue.length > 0 && gatewaySocket.readyState === WebSocket.OPEN) {
824
+ const nextFrame = sessionBridge.queue.shift();
825
+ if (typeof nextFrame === 'string' && nextFrame) {
826
+ gatewaySocket.send(nextFrame);
827
+ }
828
+ }
829
+ });
592
830
 
593
831
  gatewaySocket.on('message', (gatewayRaw) => {
594
832
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
595
- if (brokerSocket.readyState === WebSocket.OPEN) {
596
- brokerSocket.send(JSON.stringify({ type: 'gateway.frame', sessionId, frame }));
597
- }
833
+ sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
598
834
  });
599
835
 
600
- gatewaySocket.on('close', () => {
836
+ gatewaySocket.on('close', (code, reason) => {
837
+ const reasonText = reason ? reason.toString() : '';
838
+ console.log(
839
+ `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
840
+ );
601
841
  activeGatewaySockets.delete(sessionId);
602
- if (brokerSocket.readyState === WebSocket.OPEN) {
603
- brokerSocket.send(JSON.stringify({ type: 'gateway.closed', sessionId }));
604
- }
842
+ sendBrokerPayload(brokerSocket, {
843
+ action: 'gateway_closed',
844
+ type: 'gateway.closed',
845
+ sessionId,
846
+ code,
847
+ reason: reasonText,
848
+ });
605
849
  });
606
850
 
607
851
  gatewaySocket.on('error', (err) => {
608
- if (brokerSocket.readyState === WebSocket.OPEN) {
609
- brokerSocket.send(JSON.stringify({ type: 'log', level: 'error', message: `Gateway socket error (${sessionId}): ${String(err)}` }));
610
- }
852
+ console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
853
+ sendBrokerPayload(brokerSocket, {
854
+ action: 'log',
855
+ type: 'log',
856
+ sessionId,
857
+ level: 'error',
858
+ message: `Gateway socket error (${sessionId}): ${String(err)}`,
859
+ });
611
860
  });
612
861
  return;
613
862
  }
@@ -616,30 +865,47 @@ async function startOpenclawBridge(flags) {
616
865
  const sessionId = String(payload.sessionId || '').trim();
617
866
  const frame = typeof payload.frame === 'string' ? payload.frame : '';
618
867
  if (!sessionId || !frame) return;
619
- const gatewaySocket = activeGatewaySockets.get(sessionId);
620
- if (!gatewaySocket || gatewaySocket.readyState !== WebSocket.OPEN) return;
868
+ console.log(`[bridge] client.frame ${sessionId}`);
869
+ const sessionBridge = activeGatewaySockets.get(sessionId);
870
+ if (!sessionBridge || !sessionBridge.socket) {
871
+ console.log(`[bridge] client.frame dropped (no session) ${sessionId}`);
872
+ return;
873
+ }
874
+ const gatewaySocket = sessionBridge.socket;
621
875
  const frameWithAuth = injectGatewayAuth(frame, gateway);
622
- gatewaySocket.send(frameWithAuth);
876
+ if (gatewaySocket.readyState === WebSocket.OPEN) {
877
+ gatewaySocket.send(frameWithAuth);
878
+ } else if (gatewaySocket.readyState === WebSocket.CONNECTING) {
879
+ console.log(`[bridge] client.frame queued ${sessionId}`);
880
+ sessionBridge.queue.push(frameWithAuth);
881
+ } else {
882
+ console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
883
+ }
623
884
  return;
624
885
  }
625
886
 
626
887
  if (payload.type === 'client.close') {
627
888
  const sessionId = String(payload.sessionId || '').trim();
628
- const gatewaySocket = activeGatewaySockets.get(sessionId);
629
- if (gatewaySocket) {
889
+ console.log(`[bridge] client.close ${sessionId}`);
890
+ const sessionBridge = activeGatewaySockets.get(sessionId);
891
+ if (sessionBridge && sessionBridge.socket) {
630
892
  activeGatewaySockets.delete(sessionId);
631
- gatewaySocket.close(1000, 'client_closed');
893
+ sessionBridge.socket.close(1000, 'client_closed');
632
894
  }
633
895
  return;
634
896
  }
635
897
  });
636
898
 
637
899
  brokerSocket.on('close', (code, reason) => {
900
+ if (actionCableHeartbeat) {
901
+ clearInterval(actionCableHeartbeat);
902
+ actionCableHeartbeat = null;
903
+ }
638
904
  console.log(`[bridge] Broker disconnected (${code}) ${reason.toString()}`);
639
- for (const [sessionId, gatewaySocket] of activeGatewaySockets.entries()) {
905
+ for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
640
906
  activeGatewaySockets.delete(sessionId);
641
907
  try {
642
- gatewaySocket.close(1001, 'broker_disconnected');
908
+ sessionBridge.socket.close(1001, 'broker_disconnected');
643
909
  } catch {
644
910
  // no-op
645
911
  }
@@ -664,7 +930,6 @@ async function pairAndStartOpenclawBridge(flags) {
664
930
  const sessionKey = String(
665
931
  flags['session-key'] ||
666
932
  process.env.OOMI_SESSION_KEY ||
667
- process.env.NEXT_PUBLIC_SESSION_KEY ||
668
933
  'agent:main:webchat:channel:oomi'
669
934
  ).trim();
670
935
  const detach = Boolean(flags.detach);
@@ -768,6 +1033,73 @@ async function pairAndStartOpenclawBridge(flags) {
768
1033
  });
769
1034
  }
770
1035
 
1036
+ function printOpenclawPluginSetup(flags) {
1037
+ const bridgeState = readBridgeState();
1038
+ const backendUrl = String(
1039
+ flags['backend-url'] ||
1040
+ process.env.OOMI_BACKEND_URL ||
1041
+ process.env.OOMI_CHAT_BROKER_HTTP_URL ||
1042
+ bridgeState.brokerHttp ||
1043
+ ''
1044
+ ).trim();
1045
+ const deviceToken = String(
1046
+ flags['device-token'] ||
1047
+ bridgeState.deviceToken ||
1048
+ ''
1049
+ ).trim();
1050
+ const showSecrets = isTruthyFlag(flags['show-secrets']);
1051
+ const redactToken = (value) => {
1052
+ if (!value) return '';
1053
+ if (showSecrets) return value;
1054
+ if (value.length <= 12) return '***';
1055
+ return `${value.slice(0, 6)}...${value.slice(-6)}`;
1056
+ };
1057
+ const defaultSessionKey = String(
1058
+ flags['session-key'] ||
1059
+ process.env.OOMI_SESSION_KEY ||
1060
+ 'agent:main:webchat:channel:oomi'
1061
+ ).trim();
1062
+
1063
+ console.log('OpenClaw Oomi Plugin Setup');
1064
+ console.log('--------------------------');
1065
+ console.log('1) Install extension package in OpenClaw:');
1066
+ console.log(' openclaw plugins install oomi-ai@latest');
1067
+ console.log('');
1068
+ console.log('2) Configure OpenClaw channel account (channels.oomi.accounts.default):');
1069
+ console.log(
1070
+ JSON.stringify(
1071
+ {
1072
+ channels: {
1073
+ oomi: {
1074
+ defaultAccountId: 'default',
1075
+ accounts: {
1076
+ default: {
1077
+ enabled: true,
1078
+ backendUrl,
1079
+ deviceToken: redactToken(deviceToken),
1080
+ defaultSessionKey,
1081
+ },
1082
+ },
1083
+ },
1084
+ },
1085
+ },
1086
+ null,
1087
+ 2
1088
+ )
1089
+ );
1090
+ if (deviceToken && !showSecrets) {
1091
+ console.log('Token is redacted by default. Use --show-secrets to print full values.');
1092
+ console.log(`Bridge state file: ${resolveBridgeStatePath()}`);
1093
+ }
1094
+ console.log('');
1095
+
1096
+ if (!backendUrl || !deviceToken) {
1097
+ console.log('Missing backend/device credentials in local state.');
1098
+ console.log('Run: oomi openclaw pair --app-url https://www.oomi.ai --no-start');
1099
+ console.log('Then run: oomi openclaw plugin');
1100
+ }
1101
+ }
1102
+
771
1103
  async function main() {
772
1104
  const args = parseArgs(process.argv);
773
1105
  const command = args.command;
@@ -778,6 +1110,8 @@ async function main() {
778
1110
  process.exit(0);
779
1111
  }
780
1112
 
1113
+ await maybeNotifyUpdate(command);
1114
+
781
1115
  if (command === 'init') {
782
1116
  const agentsPath = resolveAgentsFile(args.flags['agents-file'], args.flags.workspace);
783
1117
  installInstructions(agentsPath);
@@ -807,6 +1141,11 @@ async function main() {
807
1141
  return;
808
1142
  }
809
1143
 
1144
+ if (command === 'openclaw' && subcommand === 'plugin') {
1145
+ printOpenclawPluginSetup(args.flags);
1146
+ return;
1147
+ }
1148
+
810
1149
  if (command === 'personas' && subcommand === 'sync') {
811
1150
  await syncPersonas({ backendUrl: args.flags['backend-url'], root: args.flags.root });
812
1151
  return;
@@ -0,0 +1,247 @@
1
+ const CHANNEL_ID = 'oomi';
2
+ const DEFAULT_SESSION_KEY = 'agent:main:webchat:channel:oomi';
3
+ const DEFAULT_TIMEOUT_MS = 15000;
4
+
5
+ function toString(value, fallback = '') {
6
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
7
+ }
8
+
9
+ function toNumber(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
10
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
11
+ const normalized = Math.floor(value);
12
+ if (normalized < min) return fallback;
13
+ if (normalized > max) return max;
14
+ return normalized;
15
+ }
16
+
17
+ function parseAccounts(rawAccounts) {
18
+ if (!rawAccounts || typeof rawAccounts !== 'object') return {};
19
+ const accounts = {};
20
+
21
+ for (const [accountId, raw] of Object.entries(rawAccounts)) {
22
+ if (!raw || typeof raw !== 'object') continue;
23
+ accounts[accountId] = {
24
+ enabled: raw.enabled !== false,
25
+ backendUrl: toString(raw.backendUrl),
26
+ deviceToken: toString(raw.deviceToken),
27
+ defaultSessionKey: toString(raw.defaultSessionKey, DEFAULT_SESSION_KEY),
28
+ requestTimeoutMs: toNumber(raw.requestTimeoutMs, DEFAULT_TIMEOUT_MS, { min: 2000, max: 120000 }),
29
+ };
30
+ }
31
+
32
+ return accounts;
33
+ }
34
+
35
+ function normalizeConfig(cfg = {}) {
36
+ const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
37
+ const configuredAccounts = parseAccounts(channelConfig.accounts);
38
+ const accountIds = Object.keys(configuredAccounts);
39
+ const defaultAccountId = toString(channelConfig.defaultAccountId, accountIds[0] || 'default');
40
+
41
+ if (!configuredAccounts[defaultAccountId]) {
42
+ configuredAccounts[defaultAccountId] = {
43
+ enabled: true,
44
+ backendUrl: '',
45
+ deviceToken: '',
46
+ defaultSessionKey: DEFAULT_SESSION_KEY,
47
+ requestTimeoutMs: DEFAULT_TIMEOUT_MS,
48
+ };
49
+ }
50
+
51
+ return {
52
+ defaultAccountId,
53
+ accounts: configuredAccounts,
54
+ };
55
+ }
56
+
57
+ function resolveAccount(cfg, accountId) {
58
+ const normalized = normalizeConfig(cfg);
59
+ const resolvedId = toString(accountId, normalized.defaultAccountId);
60
+ const account = normalized.accounts[resolvedId];
61
+ if (!account) {
62
+ return {
63
+ accountId: resolvedId,
64
+ account: null,
65
+ };
66
+ }
67
+
68
+ return {
69
+ accountId: resolvedId,
70
+ account,
71
+ };
72
+ }
73
+
74
+ function extractText(payload) {
75
+ if (!payload) return '';
76
+ if (typeof payload === 'string') return payload.trim();
77
+
78
+ const direct = [payload.text, payload.message, payload.content, payload.body];
79
+ for (const value of direct) {
80
+ if (typeof value === 'string' && value.trim()) return value.trim();
81
+ }
82
+
83
+ if (Array.isArray(payload.content)) {
84
+ return payload.content
85
+ .filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
86
+ .map((part) => part.text.trim())
87
+ .filter(Boolean)
88
+ .join('\n');
89
+ }
90
+
91
+ return '';
92
+ }
93
+
94
+ function extractConversationKey(payload) {
95
+ const candidates = [
96
+ payload?.conversationKey,
97
+ payload?.threadId,
98
+ payload?.target?.conversationKey,
99
+ payload?.target?.threadId,
100
+ payload?.target?.id,
101
+ payload?.metadata?.conversationKey,
102
+ payload?.metadata?.threadId,
103
+ ];
104
+
105
+ for (const candidate of candidates) {
106
+ const value = toString(candidate);
107
+ if (value) return value;
108
+ }
109
+
110
+ return '';
111
+ }
112
+
113
+ function extractUserId(payload) {
114
+ const candidates = [
115
+ payload?.userId,
116
+ payload?.target?.userId,
117
+ payload?.metadata?.userId,
118
+ ];
119
+
120
+ for (const candidate of candidates) {
121
+ const value = toString(candidate);
122
+ if (value) return value;
123
+ }
124
+
125
+ return '';
126
+ }
127
+
128
+ async function postJson({ url, token, body, timeoutMs }) {
129
+ const controller = new AbortController();
130
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
131
+
132
+ try {
133
+ const response = await fetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ Authorization: `Bearer ${token}`,
138
+ },
139
+ body: JSON.stringify(body),
140
+ signal: controller.signal,
141
+ });
142
+
143
+ const payload = await response.json().catch(() => ({}));
144
+ return {
145
+ ok: response.ok,
146
+ status: response.status,
147
+ payload,
148
+ };
149
+ } finally {
150
+ clearTimeout(timeout);
151
+ }
152
+ }
153
+
154
+ const oomiChannelPlugin = {
155
+ id: CHANNEL_ID,
156
+ meta: {
157
+ name: 'Oomi',
158
+ description: 'Managed Oomi channel plugin.',
159
+ },
160
+ capabilities: {
161
+ chatTypes: ['direct'],
162
+ media: {
163
+ images: false,
164
+ audio: false,
165
+ files: false,
166
+ },
167
+ threads: true,
168
+ },
169
+
170
+ config(cfg) {
171
+ return normalizeConfig(cfg);
172
+ },
173
+
174
+ listAccountIds(cfg) {
175
+ const normalized = normalizeConfig(cfg);
176
+ return Object.entries(normalized.accounts)
177
+ .filter(([, account]) => account.enabled !== false)
178
+ .map(([accountId]) => accountId);
179
+ },
180
+
181
+ outbound: {
182
+ deliveryMode: 'direct',
183
+
184
+ async sendText(payload = {}) {
185
+ const { cfg, accountId } = payload;
186
+ const { accountId: resolvedAccountId, account } = resolveAccount(cfg, accountId);
187
+
188
+ if (!account || account.enabled === false) {
189
+ return {
190
+ ok: false,
191
+ error: `oomi account is disabled or missing (${resolvedAccountId})`,
192
+ };
193
+ }
194
+ if (!account.backendUrl || !account.deviceToken) {
195
+ return {
196
+ ok: false,
197
+ error: `oomi account is missing backendUrl/deviceToken (${resolvedAccountId})`,
198
+ };
199
+ }
200
+
201
+ const content = extractText(payload);
202
+ if (!content) {
203
+ return {
204
+ ok: false,
205
+ error: 'oomi outbound message content is empty',
206
+ };
207
+ }
208
+
209
+ const conversationKey = extractConversationKey(payload);
210
+ const userId = extractUserId(payload);
211
+ const sessionKey = toString(payload?.sessionKey || payload?.metadata?.sessionKey, account.defaultSessionKey);
212
+
213
+ const response = await postJson({
214
+ url: `${account.backendUrl}/v1/channel/plugin/messages`,
215
+ token: account.deviceToken,
216
+ timeoutMs: account.requestTimeoutMs,
217
+ body: {
218
+ conversationKey,
219
+ userId,
220
+ sessionKey,
221
+ content,
222
+ source: 'openclaw.channel',
223
+ metadata: {
224
+ accountId: resolvedAccountId,
225
+ },
226
+ },
227
+ });
228
+
229
+ if (!response.ok) {
230
+ const reason = toString(response.payload?.error, `status ${response.status}`);
231
+ return {
232
+ ok: false,
233
+ error: `oomi plugin message publish failed: ${reason}`,
234
+ };
235
+ }
236
+
237
+ return {
238
+ ok: true,
239
+ providerMessageId: toString(response.payload?.message?.messageId),
240
+ };
241
+ },
242
+ },
243
+ };
244
+
245
+ export default function register(api) {
246
+ api.registerChannel({ plugin: oomiChannelPlugin });
247
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "oomi-ai",
3
+ "name": "Oomi Channel Plugin",
4
+ "description": "Managed Oomi channel integration for OpenClaw.",
5
+ "version": "0.2.1",
6
+ "author": "Oomi",
7
+ "license": "MIT",
8
+ "openclawVersion": ">=0.5.0",
9
+ "channels": [
10
+ "oomi"
11
+ ],
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {}
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -9,6 +9,26 @@
9
9
  "engines": {
10
10
  "node": ">=18"
11
11
  },
12
+ "openclaw": {
13
+ "extensions": [
14
+ "./openclaw.extension.js"
15
+ ],
16
+ "channel": {
17
+ "id": "oomi",
18
+ "label": "Oomi",
19
+ "selectionLabel": "Oomi (Managed)",
20
+ "docsPath": "/channels/oomi",
21
+ "docsLabel": "oomi",
22
+ "blurb": "Managed channel transport for Oomi chat.",
23
+ "aliases": [
24
+ "oomi-ai"
25
+ ]
26
+ },
27
+ "install": {
28
+ "npmSpec": "oomi-ai",
29
+ "defaultChoice": "npm"
30
+ }
31
+ },
12
32
  "keywords": [
13
33
  "oomi",
14
34
  "openclaw",
@@ -19,23 +39,25 @@
19
39
  "homepage": "https://oomi.ai",
20
40
  "repository": {
21
41
  "type": "git",
22
- "url": "https://github.com/crispcode-io/oomi.git",
42
+ "url": "git+https://github.com/crispcode-io/oomi.git",
23
43
  "directory": "packages/oomi-ai"
24
44
  },
25
45
  "bugs": {
26
46
  "url": "https://github.com/crispcode-io/oomi/issues"
27
47
  },
48
+ "scripts": {
49
+ "check": "node --check bin/oomi-ai.js"
50
+ },
28
51
  "dependencies": {
29
52
  "ws": "^8.19.0"
30
53
  },
31
54
  "license": "MIT",
32
55
  "files": [
33
56
  "bin/oomi-ai.js",
57
+ "openclaw.plugin.json",
58
+ "openclaw.extension.js",
34
59
  "agent_instructions.md",
35
60
  "README.md",
36
61
  "skills/oomi"
37
- ],
38
- "scripts": {
39
- "check": "node --check bin/oomi-ai.js"
40
- }
41
- }
62
+ ]
63
+ }
@@ -15,10 +15,21 @@ 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/web:
17
17
 
18
- 1. Ensure `oomi-ai` is installed (`pnpm add -g oomi-ai`).
19
- 2. Run:
20
- - `oomi openclaw pair --app-url https://oomi.ai --detach`
21
- 3. Return this exact result format to the user:
18
+ 1. Refresh bootstrap/update first:
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:
27
+ - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
+ 6. Restart OpenClaw.
29
+ 7. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
30
+ - Use that URL directly in the pair command.
31
+ - Example: `oomi openclaw pair --app-url <URL> --no-start`
32
+ 8. Return this exact result format to the user:
22
33
  - `Oomi Connect Ready`
23
34
  - `Code: ...`
24
35
  - `Link: ...`