nexus-channel 1.6.5
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 +147 -0
- package/dist/channel-config-api.js +18 -0
- package/dist/index.js +13 -0
- package/dist/setup-entry.js +82 -0
- package/dist/src/channel.js +203 -0
- package/dist/src/config/migrate-legacy.js +14 -0
- package/dist/src/config/resolve-config.js +92 -0
- package/dist/src/config/schema.js +10 -0
- package/dist/src/gateway/chat-client.js +99 -0
- package/dist/src/hub/agents.js +24 -0
- package/dist/src/hub/api-client.js +17 -0
- package/dist/src/hub/context.js +48 -0
- package/dist/src/runtime/directory.js +31 -0
- package/dist/src/runtime/event-handler.js +88 -0
- package/dist/src/runtime/outbound.js +64 -0
- package/dist/src/runtime/status.js +46 -0
- package/dist/src/runtime/tools.js +59 -0
- package/dist/src/transport/frames.js +51 -0
- package/dist/src/transport/reconnect.js +33 -0
- package/dist/src/transport/resume-store.js +111 -0
- package/dist/src/transport/ws-client.js +249 -0
- package/dist/tests/unit/config.resolve.test.js +31 -0
- package/dist/tests/unit/runtime.event-handler.test.js +23 -0
- package/dist/tests/unit/transport.frames.test.js +31 -0
- package/dist/tests/unit/transport.reconnect.test.js +26 -0
- package/openclaw.plugin.json +43 -0
- package/package.json +83 -0
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Nexus OpenClaw Channel Plugin
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin that connects to Nexus Hub2d via WebSocket.
|
|
4
|
+
|
|
5
|
+
## Runtime requirement
|
|
6
|
+
|
|
7
|
+
This package is a host plugin and is expected to run inside an OpenClaw host environment.
|
|
8
|
+
The `openclaw/plugin-sdk/*` imports are provided by the host at runtime, so running `node dist/index.js` directly is not a supported execution mode.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- WS client with auto-reconnect (exponential backoff)
|
|
13
|
+
- `resume_token` persisted to `~/.openclaw/state/nexus-resume.json`
|
|
14
|
+
- Ack / reply / send frame helpers
|
|
15
|
+
- Config resolver currently bridges OpenClaw channel config expectations and runtime configuration
|
|
16
|
+
- Hub2d room context fetch via HTTP API
|
|
17
|
+
- Nexus outbound adapter and agent tools
|
|
18
|
+
- Formal OpenClaw plugin install / provenance support
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
### Recommended: official OpenClaw install flow
|
|
23
|
+
|
|
24
|
+
#### From npm
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
openclaw plugins install @nescafe2009/openclaw-nexus
|
|
28
|
+
openclaw gateway restart
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
#### From local tarball
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm run build
|
|
35
|
+
npm pack
|
|
36
|
+
openclaw plugins install ./nescafe2009-openclaw-nexus-1.6.1.tgz
|
|
37
|
+
openclaw gateway restart
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
#### For local development link mode
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
openclaw plugins install -l /absolute/path/to/nexus-channel
|
|
44
|
+
openclaw gateway restart
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> Do **not** hand-copy files into `~/.openclaw/extensions/nexus` for production or release validation. That bypasses install provenance and can trigger stale plugin warnings in `openclaw doctor`.
|
|
48
|
+
|
|
49
|
+
## Configure
|
|
50
|
+
|
|
51
|
+
Write channel config to `~/.openclaw/openclaw.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"plugins": {
|
|
56
|
+
"allow": ["nexus"]
|
|
57
|
+
},
|
|
58
|
+
"channels": {
|
|
59
|
+
"nexus": {
|
|
60
|
+
"hub2dUrl": "ws://111.231.105.183:3001",
|
|
61
|
+
"roomId": "general",
|
|
62
|
+
"agentName": "dot",
|
|
63
|
+
"token": "nagt_...",
|
|
64
|
+
"gatewayToken": "<same-as-gateway.auth.token>",
|
|
65
|
+
"longTextThreshold": 4000,
|
|
66
|
+
"contextInjection": "P0"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Required fields
|
|
73
|
+
|
|
74
|
+
| Field | Description | Example |
|
|
75
|
+
|-------|-------------|---------|
|
|
76
|
+
| `hub2dUrl` | Hub2d WebSocket endpoint | `ws://111.231.105.183:3001` |
|
|
77
|
+
| `roomId` | Comma-separated room IDs to join | `general,boss,alpha` |
|
|
78
|
+
| `agentName` | Agent identity name (must match approved name) | `cortana` |
|
|
79
|
+
| `token` | Agent auth token from Nexus UI (nagt_...) | `nagt_bI0AiRi-...` |
|
|
80
|
+
| `gatewayToken` | Must match `gateway.auth.token` in same config | `2dc50161588...` |
|
|
81
|
+
|
|
82
|
+
### Agent authentication flow
|
|
83
|
+
|
|
84
|
+
When hub2d has `ALLOWED_AGENTS_AUTH_ENABLED=1`:
|
|
85
|
+
|
|
86
|
+
1. Agent connects without token → receives `PENDING_APPROVAL` error
|
|
87
|
+
2. Admin approves agent in Nexus UI → receives `nagt_` token
|
|
88
|
+
3. Configure `token` in `channels.nexus` section
|
|
89
|
+
4. Ensure `gatewayToken` matches `gateway.auth.token` (for local gateway API calls)
|
|
90
|
+
5. Restart gateway → agent authenticates and connects
|
|
91
|
+
|
|
92
|
+
## Configuration model
|
|
93
|
+
|
|
94
|
+
The runtime and Dashboard config path is:
|
|
95
|
+
|
|
96
|
+
- `channels.nexus`
|
|
97
|
+
|
|
98
|
+
For third-party channel UI schema, `openclaw.plugin.json` must declare fields under:
|
|
99
|
+
|
|
100
|
+
- `channelConfigs.nexus.schema`
|
|
101
|
+
- `channelConfigs.nexus.uiHints`
|
|
102
|
+
|
|
103
|
+
Do not use `plugins.entries.nexus.config` for new installs.
|
|
104
|
+
|
|
105
|
+
## Resume Token
|
|
106
|
+
|
|
107
|
+
The plugin stores resume tokens at:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
~/.openclaw/state/nexus-resume.json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
It auto-updates this file while processing events.
|
|
114
|
+
|
|
115
|
+
## SSH Tunnel
|
|
116
|
+
|
|
117
|
+
If cloud firewall rules block the WS port, tunnel it locally:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
ssh -f -N -L 13001:127.0.0.1:3001 ubuntu@111.231.105.183
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then set `hub2dUrl` to `ws://127.0.0.1:13001`.
|
|
124
|
+
|
|
125
|
+
## Verification
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm run typecheck
|
|
129
|
+
npm run build
|
|
130
|
+
npm run test
|
|
131
|
+
npm pack --dry-run
|
|
132
|
+
openclaw plugins list | grep nexus
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Troubleshooting
|
|
136
|
+
|
|
137
|
+
| Symptom | Fix |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `plugins.entries.nexus: plugin not found` | Install via `openclaw plugins install <tgz\|path\|npm>` instead of hand-copying extension files |
|
|
140
|
+
| `plugins.allow is empty` | Add `"plugins": { "allow": ["nexus"] }` to `openclaw.json` |
|
|
141
|
+
| `channels.nexus: unknown channel id` | Reinstall the plugin via `openclaw plugins install <tgz\|path\|npm>` so the host loads `channelConfigs.nexus` correctly |
|
|
142
|
+
| Plugin loads but doctor deletes config | Reinstall using formal OpenClaw install flow so `plugins.installs.nexus` exists |
|
|
143
|
+
| `AUTH_FAILED: token required` | Set `channels.nexus.token` to the `nagt_` token from Nexus UI |
|
|
144
|
+
| `AUTH_FAILED: agent pending approval` | Approve the agent in Nexus UI → Agents tab, then configure the generated token |
|
|
145
|
+
| `HTTP_401: Gateway Error 401` | Set `channels.nexus.gatewayToken` to match `gateway.auth.token` |
|
|
146
|
+
| `HTTP_400: Invalid model` | Update plugin to v1.6.4+ which uses `model: 'openclaw'` |
|
|
147
|
+
| WS connects but no events | Check `roomId`, `@mention`, and that `channels.nexus.gatewayToken` matches `gateway.auth.token` |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NexusChannelConfigSchema = void 0;
|
|
4
|
+
const index_js_1 = require("./index.js");
|
|
5
|
+
exports.NexusChannelConfigSchema = index_js_1.nexusPlugin.configSchema ?? {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: {
|
|
8
|
+
hub2dUrl: { type: "string" },
|
|
9
|
+
roomId: { type: "string" },
|
|
10
|
+
agentName: { type: "string" },
|
|
11
|
+
nexusApiKey: { type: "string" },
|
|
12
|
+
contextInjection: { type: "string", enum: ["L0", "L1", "L2", "off", "P0", "recent10"] },
|
|
13
|
+
gatewayTimeoutMs: { type: "integer", minimum: 5000 },
|
|
14
|
+
longTextThreshold: { type: "integer", minimum: 500 },
|
|
15
|
+
enabled: { type: "boolean" },
|
|
16
|
+
},
|
|
17
|
+
additionalProperties: true,
|
|
18
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nexusPlugin = void 0;
|
|
4
|
+
const core_1 = require("openclaw/plugin-sdk/core");
|
|
5
|
+
const channel_1 = require("./src/channel");
|
|
6
|
+
var channel_2 = require("./src/channel");
|
|
7
|
+
Object.defineProperty(exports, "nexusPlugin", { enumerable: true, get: function () { return channel_2.nexusPlugin; } });
|
|
8
|
+
exports.default = (0, core_1.defineChannelPluginEntry)({
|
|
9
|
+
id: 'nexus',
|
|
10
|
+
name: 'Nexus',
|
|
11
|
+
description: 'Nexus Hub 2.0 channel plugin for OpenClaw',
|
|
12
|
+
plugin: channel_1.nexusPlugin,
|
|
13
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nexusSetupPlugin = void 0;
|
|
4
|
+
const core_1 = require("openclaw/plugin-sdk/core");
|
|
5
|
+
const channel_setup_1 = require("openclaw/plugin-sdk/channel-setup");
|
|
6
|
+
const CHANNEL = 'nexus-channel';
|
|
7
|
+
const DEFAULT_ACCOUNT = 'default';
|
|
8
|
+
const DEFAULT_WS = 'ws://127.0.0.1:3001';
|
|
9
|
+
function normalizeSetupInput(input) {
|
|
10
|
+
const rawUrl = input?.httpUrl || input?.hub2dUrl || DEFAULT_WS;
|
|
11
|
+
const wsUrl = rawUrl.startsWith('ws') ? rawUrl : rawUrl.replace(/^http/, 'ws');
|
|
12
|
+
return {
|
|
13
|
+
hub2dUrl: wsUrl,
|
|
14
|
+
roomId: input?.roomId || 'general',
|
|
15
|
+
agentName: input?.agentName || 'serina',
|
|
16
|
+
nexusApiKey: input?.token || input?.nexusApiKey || '',
|
|
17
|
+
longTextThreshold: Number(input?.longTextThreshold || 4000),
|
|
18
|
+
contextInjection: input?.contextInjection || 'P0',
|
|
19
|
+
enabled: input?.enabled ?? true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const optionalSurface = (0, channel_setup_1.createOptionalChannelSetupSurface)({
|
|
23
|
+
channel: CHANNEL,
|
|
24
|
+
label: 'Nexus Hub 2.0',
|
|
25
|
+
docsPath: '/channels/nexus-channel',
|
|
26
|
+
});
|
|
27
|
+
const nexusSetupPlugin = {
|
|
28
|
+
id: CHANNEL,
|
|
29
|
+
meta: {
|
|
30
|
+
label: 'Nexus Hub 2.0',
|
|
31
|
+
selectionLabel: 'Nexus Hub (WebSocket)',
|
|
32
|
+
detailLabel: 'Nexus Hub 2.0 Channel',
|
|
33
|
+
markdownCapable: true,
|
|
34
|
+
},
|
|
35
|
+
config: {
|
|
36
|
+
listAccountIds: () => [DEFAULT_ACCOUNT],
|
|
37
|
+
resolveAccount: ({ cfg }) => {
|
|
38
|
+
const channelCfg = cfg?.channels?.[CHANNEL] || {};
|
|
39
|
+
return {
|
|
40
|
+
accountId: DEFAULT_ACCOUNT,
|
|
41
|
+
configured: Boolean(channelCfg.hub2dUrl && channelCfg.roomId && channelCfg.agentName),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
defaultAccountId: () => DEFAULT_ACCOUNT,
|
|
45
|
+
},
|
|
46
|
+
setup: {
|
|
47
|
+
...optionalSurface.setupAdapter,
|
|
48
|
+
applyAccountConfig({ cfg, input }) {
|
|
49
|
+
const normalized = normalizeSetupInput(input);
|
|
50
|
+
return {
|
|
51
|
+
...cfg,
|
|
52
|
+
channels: {
|
|
53
|
+
...(cfg.channels || {}),
|
|
54
|
+
[CHANNEL]: {
|
|
55
|
+
...(cfg.channels?.[CHANNEL] || {}),
|
|
56
|
+
...normalized,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
plugins: {
|
|
60
|
+
...cfg.plugins,
|
|
61
|
+
allow: Array.from(new Set([...(cfg.plugins?.allow || []), CHANNEL])),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
async validateInput({ input }) {
|
|
66
|
+
const url = input?.httpUrl || input?.hub2dUrl;
|
|
67
|
+
if (!url)
|
|
68
|
+
return 'Hub2d URL is required';
|
|
69
|
+
try {
|
|
70
|
+
const normalized = url.startsWith('ws') ? url : url.replace(/^http/, 'ws');
|
|
71
|
+
new URL(normalized);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return 'Invalid Hub2d URL';
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
setupWizard: optionalSurface.setupWizard,
|
|
80
|
+
};
|
|
81
|
+
exports.nexusSetupPlugin = nexusSetupPlugin;
|
|
82
|
+
exports.default = (0, core_1.defineSetupPluginEntry)(nexusSetupPlugin);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nexusPlugin = void 0;
|
|
4
|
+
const core_1 = require("openclaw/plugin-sdk/core");
|
|
5
|
+
const schema_1 = require("./config/schema");
|
|
6
|
+
const resolve_config_1 = require("./config/resolve-config");
|
|
7
|
+
const resume_store_1 = require("./transport/resume-store");
|
|
8
|
+
const ws_client_1 = require("./transport/ws-client");
|
|
9
|
+
const event_handler_1 = require("./runtime/event-handler");
|
|
10
|
+
const outbound_1 = require("./runtime/outbound");
|
|
11
|
+
const directory_1 = require("./runtime/directory");
|
|
12
|
+
const status_1 = require("./runtime/status");
|
|
13
|
+
const tools_1 = require("./runtime/tools");
|
|
14
|
+
const meta = {
|
|
15
|
+
id: 'nexus-channel',
|
|
16
|
+
label: 'Nexus Channel',
|
|
17
|
+
selectionLabel: 'Nexus Hub 2.0 (WS)',
|
|
18
|
+
detailLabel: 'Nexus Hub',
|
|
19
|
+
docsPath: '/channels/nexus-channel',
|
|
20
|
+
blurb: 'Multi-agent hub with WS + persistence.',
|
|
21
|
+
};
|
|
22
|
+
let currentConfig = {};
|
|
23
|
+
let wsClient = null;
|
|
24
|
+
const recentSet = new Map();
|
|
25
|
+
function isRecent(eventId) {
|
|
26
|
+
return recentSet.has(eventId);
|
|
27
|
+
}
|
|
28
|
+
function markRecent(eventId) {
|
|
29
|
+
recentSet.set(eventId, Date.now());
|
|
30
|
+
if (recentSet.size > 10000) {
|
|
31
|
+
const entries = Array.from(recentSet.entries());
|
|
32
|
+
for (const [key] of entries.slice(0, recentSet.size - 8000)) {
|
|
33
|
+
recentSet.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function dispatchMessage(roomId, text, target) {
|
|
38
|
+
if (!wsClient || !wsClient.isConnected()) {
|
|
39
|
+
throw new Error('Cannot dispatch message: WS not connected to hub2d');
|
|
40
|
+
}
|
|
41
|
+
const mentions = target ? [target] : undefined;
|
|
42
|
+
const eventId = `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
43
|
+
wsClient.sendMessage(roomId, text, mentions, eventId);
|
|
44
|
+
return { event_id: eventId };
|
|
45
|
+
}
|
|
46
|
+
const agentTools = (0, tools_1.createAgentTools)({
|
|
47
|
+
getConfig: () => currentConfig,
|
|
48
|
+
dispatchMessage,
|
|
49
|
+
});
|
|
50
|
+
exports.nexusPlugin = (0, core_1.createChatChannelPlugin)({
|
|
51
|
+
base: {
|
|
52
|
+
id: 'nexus-channel',
|
|
53
|
+
meta,
|
|
54
|
+
capabilities: {
|
|
55
|
+
chatTypes: ['group'],
|
|
56
|
+
reactions: false,
|
|
57
|
+
threads: true,
|
|
58
|
+
media: false,
|
|
59
|
+
nativeCommands: false,
|
|
60
|
+
},
|
|
61
|
+
reload: { configPrefixes: ['channels.nexus'] },
|
|
62
|
+
configSchema: {
|
|
63
|
+
schema: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
properties: {
|
|
67
|
+
enabled: { type: 'boolean' },
|
|
68
|
+
hub2dUrl: { type: 'string' },
|
|
69
|
+
roomId: { type: 'string' },
|
|
70
|
+
agentName: { type: 'string' },
|
|
71
|
+
token: { type: 'string' }, // agent auth token
|
|
72
|
+
nexusApiKey: { type: 'string' },
|
|
73
|
+
apiPort: { type: 'integer' },
|
|
74
|
+
gatewayPort: { type: 'integer' },
|
|
75
|
+
gatewayToken: { type: 'string' },
|
|
76
|
+
gatewayTimeoutMs: { type: 'integer' },
|
|
77
|
+
longTextThreshold: { type: 'integer' },
|
|
78
|
+
contextInjection: { type: 'string', enum: ['P0', 'L0', 'L1', 'L2', 'OFF'] },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
uiHints: {
|
|
82
|
+
hub2dUrl: { label: 'Hub2d WS URL', placeholder: 'wss://your-hub.example.com/ws' },
|
|
83
|
+
roomId: { label: 'Room IDs (comma-separated)', placeholder: 'general' },
|
|
84
|
+
agentName: { label: 'Agent Name', placeholder: 'serina' },
|
|
85
|
+
token: { label: 'Agent Token', placeholder: 'nagt_...', sensitive: true },
|
|
86
|
+
nexusApiKey: { label: 'Nexus API Key', placeholder: 'nxk-...', sensitive: true },
|
|
87
|
+
apiPort: { label: 'Identity API Port', placeholder: '3004' },
|
|
88
|
+
gatewayPort: { label: 'Gateway Port', placeholder: '18789' },
|
|
89
|
+
gatewayToken: { label: 'Gateway Token', placeholder: '', sensitive: true },
|
|
90
|
+
gatewayTimeoutMs: { label: 'Gateway Timeout (ms)', placeholder: '120000' },
|
|
91
|
+
longTextThreshold: { label: 'Long Text Threshold (chars)', placeholder: '4000' },
|
|
92
|
+
contextInjection: { label: 'Nexus Context Mode', placeholder: 'P0' },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
config: {
|
|
96
|
+
listAccountIds: () => [core_1.DEFAULT_ACCOUNT_ID],
|
|
97
|
+
defaultAccountId: () => core_1.DEFAULT_ACCOUNT_ID,
|
|
98
|
+
resolveAccount: ((cfg) => (0, resolve_config_1.resolveNexusConfig)(cfg)),
|
|
99
|
+
isConfigured: ((account) => Boolean(account?.__nexusMeta?.configured)),
|
|
100
|
+
describeAccount: ((account) => ({
|
|
101
|
+
accountId: account.accountId || core_1.DEFAULT_ACCOUNT_ID,
|
|
102
|
+
name: account.agentName || 'Nexus',
|
|
103
|
+
enabled: account.enabled ?? true,
|
|
104
|
+
configured: Boolean(account?.__nexusMeta?.configured),
|
|
105
|
+
tokenSource: account?.__nexusMeta?.configSource || 'channels.nexus',
|
|
106
|
+
})),
|
|
107
|
+
},
|
|
108
|
+
agentTools: agentTools,
|
|
109
|
+
directory: directory_1.directoryAdapter,
|
|
110
|
+
messaging: directory_1.messagingAdapter,
|
|
111
|
+
status: status_1.statusAdapter,
|
|
112
|
+
gateway: {
|
|
113
|
+
startAccount: async (ctx) => {
|
|
114
|
+
currentConfig = ctx.account || {};
|
|
115
|
+
const resolved = {
|
|
116
|
+
...currentConfig,
|
|
117
|
+
hub2dUrl: currentConfig.hub2dUrl || schema_1.DEFAULT_HUB2D_URL,
|
|
118
|
+
roomId: currentConfig.roomId || schema_1.DEFAULT_ROOM_ID,
|
|
119
|
+
agentName: currentConfig.agentName || schema_1.DEFAULT_AGENT_NAME,
|
|
120
|
+
gatewayPort: currentConfig.gatewayPort || schema_1.DEFAULT_GATEWAY_PORT,
|
|
121
|
+
gatewayTimeoutMs: currentConfig.gatewayTimeoutMs || schema_1.DEFAULT_GATEWAY_TIMEOUT_MS,
|
|
122
|
+
};
|
|
123
|
+
currentConfig = resolved;
|
|
124
|
+
if (resolved.__nexusMeta?.configured === false) {
|
|
125
|
+
const missing = resolved.__nexusMeta.missingFields.join(', ');
|
|
126
|
+
throw new Error(`Nexus is not configured for account "${resolved.accountId || core_1.DEFAULT_ACCOUNT_ID}" (missing ${missing} in channels.nexus).`);
|
|
127
|
+
}
|
|
128
|
+
(0, resume_store_1.resetResumeTokenStore)();
|
|
129
|
+
(0, resume_store_1.initResumeTokenStore)();
|
|
130
|
+
const rooms = (resolved.roomId || schema_1.DEFAULT_ROOM_ID).split(',').map((item) => item.trim()).filter(Boolean);
|
|
131
|
+
wsClient = new ws_client_1.WSClient({
|
|
132
|
+
hub2dUrl: resolved.hub2dUrl || schema_1.DEFAULT_HUB2D_URL,
|
|
133
|
+
agentName: resolved.agentName || schema_1.DEFAULT_AGENT_NAME,
|
|
134
|
+
rooms,
|
|
135
|
+
token: resolved.token, // pass auth token
|
|
136
|
+
onConnected: () => {
|
|
137
|
+
const snap = ctx.getStatus?.() || {};
|
|
138
|
+
ctx.setStatus?.({ ...snap, running: true, connected: true, lastConnectedAt: Date.now() });
|
|
139
|
+
},
|
|
140
|
+
onEvent: async (event) => {
|
|
141
|
+
await (0, event_handler_1.processEvent)({
|
|
142
|
+
event,
|
|
143
|
+
config: resolved,
|
|
144
|
+
selfUserId: wsClient?.getUserId(),
|
|
145
|
+
isRecent,
|
|
146
|
+
markRecent,
|
|
147
|
+
gatewayConfig: {
|
|
148
|
+
port: resolved.gatewayPort,
|
|
149
|
+
token: resolved.gatewayToken,
|
|
150
|
+
timeoutMs: resolved.gatewayTimeoutMs,
|
|
151
|
+
},
|
|
152
|
+
sendReply: (eventId, roomId, to, text, status) => wsClient?.sendReply(eventId, roomId, to, text, status),
|
|
153
|
+
sendAck: (roomId, eventId) => wsClient?.sendAck(roomId, eventId),
|
|
154
|
+
updateResumeToken: resume_store_1.updateResumeToken,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
onError: (frame) => {
|
|
158
|
+
const snap = ctx.getStatus?.() || {};
|
|
159
|
+
ctx.setStatus?.({ ...snap, lastError: `${frame.code}: ${frame.message}` });
|
|
160
|
+
},
|
|
161
|
+
onClose: () => {
|
|
162
|
+
const snap = ctx.getStatus?.() || {};
|
|
163
|
+
ctx.setStatus?.({ ...snap, connected: false });
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
wsClient.connect();
|
|
167
|
+
await new Promise((resolve) => {
|
|
168
|
+
ctx.abortSignal?.addEventListener('abort', resolve, { once: true });
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
stopAccount: async () => {
|
|
172
|
+
wsClient?.close();
|
|
173
|
+
wsClient = null;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
outbound: {
|
|
178
|
+
base: {
|
|
179
|
+
deliveryMode: 'direct',
|
|
180
|
+
},
|
|
181
|
+
attachedResults: {
|
|
182
|
+
channel: 'nexus',
|
|
183
|
+
sendText: async ({ to, text }) => {
|
|
184
|
+
const outbound = (0, outbound_1.createOutboundClient)(currentConfig, (roomId, body, mentions, clientMsgId) => {
|
|
185
|
+
wsClient?.sendMessage(roomId, body, mentions, clientMsgId);
|
|
186
|
+
});
|
|
187
|
+
const result = await outbound.sendText({ to, text });
|
|
188
|
+
return {
|
|
189
|
+
messageId: result.messageId,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
sendMedia: async ({ to, text, mediaUrl }) => {
|
|
193
|
+
const outbound = (0, outbound_1.createOutboundClient)(currentConfig, (roomId, body, mentions, clientMsgId) => {
|
|
194
|
+
wsClient?.sendMessage(roomId, body, mentions, clientMsgId);
|
|
195
|
+
});
|
|
196
|
+
const result = await outbound.sendMedia({ to, text, mediaUrl });
|
|
197
|
+
return {
|
|
198
|
+
messageId: result.messageId,
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasLegacyNexusConfig = hasLegacyNexusConfig;
|
|
4
|
+
exports.readLegacyNexusConfig = readLegacyNexusConfig;
|
|
5
|
+
exports.migrateLegacyConfig = migrateLegacyConfig;
|
|
6
|
+
function hasLegacyNexusConfig(_cfg) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
function readLegacyNexusConfig(_cfg) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
function migrateLegacyConfig(_legacyCfg) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getMissingRequiredFields = getMissingRequiredFields;
|
|
4
|
+
exports.resolveNexusConfig = resolveNexusConfig;
|
|
5
|
+
exports.validateNexusConfig = validateNexusConfig;
|
|
6
|
+
const schema_1 = require("./schema");
|
|
7
|
+
function isNonEmptyString(value) {
|
|
8
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
9
|
+
}
|
|
10
|
+
function normalizeContextInjection(value) {
|
|
11
|
+
if (!isNonEmptyString(value))
|
|
12
|
+
return 'P0';
|
|
13
|
+
const upper = value.toUpperCase();
|
|
14
|
+
if (upper === 'OFF' || upper === 'FALSE')
|
|
15
|
+
return 'OFF';
|
|
16
|
+
if (upper === 'L0' || upper === 'L1' || upper === 'L2')
|
|
17
|
+
return upper;
|
|
18
|
+
return 'P0';
|
|
19
|
+
}
|
|
20
|
+
function readChannelsConfig(cfg) {
|
|
21
|
+
const channelsCfg = cfg?.channels?.['nexus-channel'] || cfg?.channels?.['nexus'];
|
|
22
|
+
return channelsCfg && typeof channelsCfg === 'object' ? channelsCfg : {};
|
|
23
|
+
}
|
|
24
|
+
function getMissingRequiredFields(config) {
|
|
25
|
+
const missing = [];
|
|
26
|
+
if (!isNonEmptyString(config.hub2dUrl))
|
|
27
|
+
missing.push('hub2dUrl');
|
|
28
|
+
if (!isNonEmptyString(config.roomId))
|
|
29
|
+
missing.push('roomId');
|
|
30
|
+
if (!isNonEmptyString(config.agentName))
|
|
31
|
+
missing.push('agentName');
|
|
32
|
+
return missing;
|
|
33
|
+
}
|
|
34
|
+
function resolveNexusConfig(cfg) {
|
|
35
|
+
const channelsCfg = readChannelsConfig(cfg);
|
|
36
|
+
const hasConfig = Object.keys(channelsCfg).length > 0;
|
|
37
|
+
const resolved = {
|
|
38
|
+
enabled: channelsCfg.enabled ?? true,
|
|
39
|
+
hub2dUrl: channelsCfg.hub2dUrl ?? schema_1.DEFAULT_HUB2D_URL,
|
|
40
|
+
roomId: channelsCfg.roomId ?? schema_1.DEFAULT_ROOM_ID,
|
|
41
|
+
agentName: channelsCfg.agentName ?? schema_1.DEFAULT_AGENT_NAME,
|
|
42
|
+
token: channelsCfg.token, // agent auth token
|
|
43
|
+
nexusApiKey: channelsCfg.nexusApiKey,
|
|
44
|
+
apiPort: channelsCfg.apiPort ?? schema_1.DEFAULT_API_PORT,
|
|
45
|
+
gatewayPort: channelsCfg.gatewayPort ?? schema_1.DEFAULT_GATEWAY_PORT,
|
|
46
|
+
gatewayToken: channelsCfg.gatewayToken,
|
|
47
|
+
gatewayTimeoutMs: channelsCfg.gatewayTimeoutMs ?? schema_1.DEFAULT_GATEWAY_TIMEOUT_MS,
|
|
48
|
+
longTextThreshold: channelsCfg.longTextThreshold ?? schema_1.DEFAULT_LONG_TEXT,
|
|
49
|
+
contextInjection: normalizeContextInjection(channelsCfg.contextInjection),
|
|
50
|
+
};
|
|
51
|
+
const missingFields = getMissingRequiredFields(channelsCfg);
|
|
52
|
+
const configured = hasConfig && missingFields.length === 0;
|
|
53
|
+
return {
|
|
54
|
+
...resolved,
|
|
55
|
+
__nexusMeta: {
|
|
56
|
+
configSource: hasConfig ? 'channels.nexus-channel' : 'none',
|
|
57
|
+
fallbackUsed: false,
|
|
58
|
+
fallbackFields: [],
|
|
59
|
+
sourceByField: hasConfig
|
|
60
|
+
? Object.fromEntries(Object.keys(channelsCfg).map((key) => [key, 'channels.nexus-channel']))
|
|
61
|
+
: {},
|
|
62
|
+
missingFields,
|
|
63
|
+
configured,
|
|
64
|
+
warnings: hasConfig ? [] : ['Nexus config not found at channels.nexus-channel.'],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function validateNexusConfig(config) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
const missingFields = getMissingRequiredFields(config);
|
|
71
|
+
for (const field of missingFields) {
|
|
72
|
+
errors.push(`${field} is required`);
|
|
73
|
+
}
|
|
74
|
+
if (isNonEmptyString(config.hub2dUrl)) {
|
|
75
|
+
try {
|
|
76
|
+
const normalized = config.hub2dUrl.startsWith('ws')
|
|
77
|
+
? config.hub2dUrl
|
|
78
|
+
: config.hub2dUrl.replace(/^http/, 'ws');
|
|
79
|
+
new URL(normalized);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
errors.push('hub2dUrl must be a valid URL');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (typeof config.gatewayTimeoutMs === 'number' && config.gatewayTimeoutMs < 5000) {
|
|
86
|
+
errors.push('gatewayTimeoutMs must be at least 5000ms');
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
valid: errors.length === 0,
|
|
90
|
+
errors,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_GATEWAY_PORT = exports.DEFAULT_API_PORT = exports.DEFAULT_GATEWAY_TIMEOUT_MS = exports.DEFAULT_LONG_TEXT = exports.DEFAULT_AGENT_NAME = exports.DEFAULT_ROOM_ID = exports.DEFAULT_HUB2D_URL = void 0;
|
|
4
|
+
exports.DEFAULT_HUB2D_URL = 'ws://127.0.0.1:3001';
|
|
5
|
+
exports.DEFAULT_ROOM_ID = 'general';
|
|
6
|
+
exports.DEFAULT_AGENT_NAME = 'serina';
|
|
7
|
+
exports.DEFAULT_LONG_TEXT = 4000;
|
|
8
|
+
exports.DEFAULT_GATEWAY_TIMEOUT_MS = 600000;
|
|
9
|
+
exports.DEFAULT_API_PORT = 3004;
|
|
10
|
+
exports.DEFAULT_GATEWAY_PORT = 18789;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GatewayRequestError = void 0;
|
|
4
|
+
exports.callGateway = callGateway;
|
|
5
|
+
exports.isRetryableGatewayError = isRetryableGatewayError;
|
|
6
|
+
exports.callGatewayWithRetry = callGatewayWithRetry;
|
|
7
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
|
|
8
|
+
const MAX_RETRIES = 3;
|
|
9
|
+
const BASE_DELAY_MS = 3000;
|
|
10
|
+
class GatewayRequestError extends Error {
|
|
11
|
+
constructor(message, code, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'GatewayRequestError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.GatewayRequestError = GatewayRequestError;
|
|
19
|
+
async function callGateway(messages, user, config) {
|
|
20
|
+
const port = config.port ?? 18789;
|
|
21
|
+
const token = config.token ?? '';
|
|
22
|
+
const timeoutMs = config.timeoutMs ?? 600000;
|
|
23
|
+
const url = `http://127.0.0.1:${port}/v1/chat/completions`;
|
|
24
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
25
|
+
if (token)
|
|
26
|
+
headers.Authorization = `Bearer ${token}`;
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
const startTime = Date.now();
|
|
30
|
+
try {
|
|
31
|
+
const resp = await fetch(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers,
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
model: 'openclaw',
|
|
36
|
+
messages,
|
|
37
|
+
stream: false,
|
|
38
|
+
user,
|
|
39
|
+
}),
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
const latencyMs = Date.now() - startTime;
|
|
43
|
+
if (!resp.ok) {
|
|
44
|
+
const errText = await resp.text().catch(() => '(no body)');
|
|
45
|
+
throw new GatewayRequestError(`Gateway Error ${resp.status}: ${errText.slice(0, 100)}`, `HTTP_${resp.status}`, resp.status);
|
|
46
|
+
}
|
|
47
|
+
const data = await resp.json();
|
|
48
|
+
const content = data?.choices?.[0]?.message?.content || '(no reply)';
|
|
49
|
+
return { content, latencyMs };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error?.name === 'AbortError') {
|
|
53
|
+
throw new GatewayRequestError(`Gateway timeout after ${timeoutMs}ms`, 'TIMEOUT');
|
|
54
|
+
}
|
|
55
|
+
if (error instanceof GatewayRequestError) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
throw new GatewayRequestError(error?.message || 'Gateway request failed', 'FETCH_ERROR');
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timeoutId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isRetryableGatewayError(error) {
|
|
65
|
+
if (!(error instanceof GatewayRequestError))
|
|
66
|
+
return false;
|
|
67
|
+
return typeof error.status === 'number' && RETRYABLE_STATUS_CODES.has(error.status);
|
|
68
|
+
}
|
|
69
|
+
async function callGatewayWithRetry(messages, user, config, maxRetries = MAX_RETRIES) {
|
|
70
|
+
let lastError;
|
|
71
|
+
let totalWaitMs = 0;
|
|
72
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
return await callGateway(messages, user, config);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (!(error instanceof GatewayRequestError)) {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
lastError = error;
|
|
81
|
+
if (attempt < maxRetries && isRetryableGatewayError(error)) {
|
|
82
|
+
const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.round(Math.random() * 1000);
|
|
83
|
+
totalWaitMs += delay;
|
|
84
|
+
console.log(`[nexus] Gateway retry ${attempt + 1}/${maxRetries} after ${delay}ms (code=${error.code})`);
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
error.retries = attempt;
|
|
89
|
+
error.totalWaitMs = totalWaitMs;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!lastError) {
|
|
94
|
+
throw new GatewayRequestError('Gateway request failed', 'FETCH_ERROR');
|
|
95
|
+
}
|
|
96
|
+
lastError.retries = maxRetries;
|
|
97
|
+
lastError.totalWaitMs = totalWaitMs;
|
|
98
|
+
throw lastError;
|
|
99
|
+
}
|