nexus-channel 1.7.6 → 1.7.7
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.
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.getEventTimestamp = getEventTimestamp;
|
|
4
4
|
exports.getEventDecision = getEventDecision;
|
|
5
5
|
exports.buildEventPrompt = buildEventPrompt;
|
|
6
|
+
exports.parseSessionDirectives = parseSessionDirectives;
|
|
6
7
|
exports.processEvent = processEvent;
|
|
7
8
|
const schema_1 = require("../config/schema");
|
|
8
9
|
const chat_client_1 = require("../gateway/chat-client");
|
|
@@ -53,6 +54,33 @@ function buildEventPrompt(event, config) {
|
|
|
53
54
|
}
|
|
54
55
|
return `[from: ${from}] ${content}`;
|
|
55
56
|
}
|
|
57
|
+
// ── Orchestrator Session Key Parsing ──────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Extract and strip orchestrator session directives from event.text.
|
|
60
|
+
*
|
|
61
|
+
* hub2d injects these prefixes for orchestrated tasks:
|
|
62
|
+
* [SESSION_KEY:nexus:roomId:e:planId] — use this session key instead of room default
|
|
63
|
+
* [SESSION_RESET] — send /new to OpenClaw before dispatching
|
|
64
|
+
*
|
|
65
|
+
* Returns { text (cleaned), sessionKey (if overridden), sessionReset (boolean) }
|
|
66
|
+
*/
|
|
67
|
+
function parseSessionDirectives(rawText) {
|
|
68
|
+
let text = rawText;
|
|
69
|
+
let sessionKey = null;
|
|
70
|
+
let sessionReset = false;
|
|
71
|
+
// Extract [SESSION_KEY:xxx]
|
|
72
|
+
const keyMatch = text.match(/\[SESSION_KEY:([^\]]+)\]/);
|
|
73
|
+
if (keyMatch) {
|
|
74
|
+
sessionKey = keyMatch[1];
|
|
75
|
+
text = text.replace(/\[SESSION_KEY:[^\]]+\]\n?/, '');
|
|
76
|
+
}
|
|
77
|
+
// Extract [SESSION_RESET]
|
|
78
|
+
if (text.includes('[SESSION_RESET]')) {
|
|
79
|
+
sessionReset = true;
|
|
80
|
+
text = text.replace(/\[SESSION_RESET\]\n?/, '');
|
|
81
|
+
}
|
|
82
|
+
return { text: text.trim(), sessionKey, sessionReset };
|
|
83
|
+
}
|
|
56
84
|
async function processEvent(params) {
|
|
57
85
|
const { event, config, selfUserId, isRecent, markRecent, gatewayConfig, sendReply, sendAck, updateResumeToken } = params;
|
|
58
86
|
const decision = getEventDecision({ event, config, selfUserId, isRecent });
|
|
@@ -66,13 +94,32 @@ async function processEvent(params) {
|
|
|
66
94
|
}
|
|
67
95
|
markRecent(event.event_id);
|
|
68
96
|
try {
|
|
69
|
-
|
|
97
|
+
// Parse orchestrator session directives from event.text
|
|
98
|
+
const { text: cleanedText, sessionKey: overrideKey, sessionReset } = parseSessionDirectives(event.text || '');
|
|
99
|
+
// Build event with cleaned text (orchestrator prefixes stripped)
|
|
100
|
+
const cleanedEvent = { ...event, text: cleanedText };
|
|
101
|
+
let prompt = buildEventPrompt(cleanedEvent, config);
|
|
70
102
|
const hub2dUrl = config.hub2dUrl || 'ws://127.0.0.1:3001';
|
|
71
103
|
const context = await (0, context_1.fetchNexusContext)(roomId, hub2dUrl, config);
|
|
72
104
|
if (context) {
|
|
73
105
|
prompt = `${context}${prompt}`;
|
|
74
106
|
}
|
|
75
|
-
|
|
107
|
+
// Use overridden session key from orchestrator, or default room session
|
|
108
|
+
const sessionKey = overrideKey || `nexus:${roomId}`;
|
|
109
|
+
// If session reset requested, send /new to gateway first to clear conversation history
|
|
110
|
+
if (sessionReset) {
|
|
111
|
+
try {
|
|
112
|
+
await (0, chat_client_1.callGatewayWithRetry)([{ role: 'user', content: '/new' }], sessionKey, gatewayConfig);
|
|
113
|
+
console.log(`[nexus] Session reset: key=${sessionKey}`);
|
|
114
|
+
}
|
|
115
|
+
catch (resetErr) {
|
|
116
|
+
console.warn(`[nexus] Session reset failed for ${sessionKey}:`, resetErr);
|
|
117
|
+
}
|
|
118
|
+
// Early return: session reset events should not generate a reply
|
|
119
|
+
sendAck(roomId, event.event_id);
|
|
120
|
+
updateResumeToken(roomId, event.event_id);
|
|
121
|
+
return 'skip-reset';
|
|
122
|
+
}
|
|
76
123
|
const response = await (0, chat_client_1.callGatewayWithRetry)([{ role: 'user', content: prompt }], sessionKey, gatewayConfig);
|
|
77
124
|
sendReply(event.event_id, roomId, event.from || 'unknown', response.content, 'done');
|
|
78
125
|
sendAck(roomId, event.event_id);
|
|
@@ -43,10 +43,19 @@ const path = __importStar(require("path"));
|
|
|
43
43
|
const frames_1 = require("./frames");
|
|
44
44
|
const reconnect_1 = require("./reconnect");
|
|
45
45
|
const resume_store_1 = require("./resume-store");
|
|
46
|
+
const MAX_OUTBOX_SIZE = 100;
|
|
47
|
+
// ── Client-side liveness ───────────────────────────────────────────────────
|
|
48
|
+
const IDLE_TIMEOUT_MS = 90000; // 90 seconds — if no frame received, connection is dead
|
|
49
|
+
const IDLE_CHECK_INTERVAL_MS = 30000; // check every 30 seconds
|
|
46
50
|
class WSClient {
|
|
47
51
|
constructor(config) {
|
|
48
52
|
this.ws = null;
|
|
49
53
|
this.reconnectTimer = null;
|
|
54
|
+
// Client-side liveness tracking
|
|
55
|
+
this.lastFrameAt = 0;
|
|
56
|
+
this.idleCheckTimer = null;
|
|
57
|
+
// Outbound message queue
|
|
58
|
+
this.outbox = [];
|
|
50
59
|
this.config = config;
|
|
51
60
|
this.reconnectState = (0, reconnect_1.createReconnectState)();
|
|
52
61
|
this.state = { connected: false };
|
|
@@ -60,12 +69,15 @@ class WSClient {
|
|
|
60
69
|
catch { }
|
|
61
70
|
this.ws = null;
|
|
62
71
|
}
|
|
72
|
+
// Stop previous idle check
|
|
73
|
+
this.stopIdleCheck();
|
|
63
74
|
console.log(`[nexus] WS connecting: ${this.config.hub2dUrl} rooms=${this.config.rooms.join(',')}`);
|
|
64
75
|
this.ws = new ws_1.default(this.config.hub2dUrl);
|
|
65
76
|
this.ws.on('open', () => {
|
|
66
77
|
this.sendConnectFrame();
|
|
67
78
|
});
|
|
68
79
|
this.ws.on('message', (data) => {
|
|
80
|
+
this.lastFrameAt = Date.now();
|
|
69
81
|
this.handleMessage(data);
|
|
70
82
|
});
|
|
71
83
|
this.ws.on('close', (code, reason) => {
|
|
@@ -74,6 +86,9 @@ class WSClient {
|
|
|
74
86
|
this.ws.on('error', (error) => {
|
|
75
87
|
console.error(`[nexus] WS error: ${error.message}`);
|
|
76
88
|
});
|
|
89
|
+
// Start idle detection
|
|
90
|
+
this.lastFrameAt = Date.now();
|
|
91
|
+
this.startIdleCheck();
|
|
77
92
|
}
|
|
78
93
|
sendConnectFrame() {
|
|
79
94
|
const frame = (0, frames_1.buildConnectFrame)({
|
|
@@ -108,6 +123,61 @@ class WSClient {
|
|
|
108
123
|
return 'unknown';
|
|
109
124
|
}
|
|
110
125
|
}
|
|
126
|
+
// ── Idle detection (client-side liveness) ──────────────────────────────
|
|
127
|
+
startIdleCheck() {
|
|
128
|
+
this.stopIdleCheck();
|
|
129
|
+
this.idleCheckTimer = setInterval(() => {
|
|
130
|
+
if (!this.state.connected || !this.ws)
|
|
131
|
+
return;
|
|
132
|
+
const idle = Date.now() - this.lastFrameAt;
|
|
133
|
+
if (idle > IDLE_TIMEOUT_MS) {
|
|
134
|
+
console.warn(`[nexus] No frames received for ${Math.round(idle / 1000)}s, terminating stale connection`);
|
|
135
|
+
try {
|
|
136
|
+
this.ws.terminate();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// terminate triggers the close handler, which handles reconnect
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}, IDLE_CHECK_INTERVAL_MS);
|
|
143
|
+
}
|
|
144
|
+
stopIdleCheck() {
|
|
145
|
+
if (this.idleCheckTimer) {
|
|
146
|
+
clearInterval(this.idleCheckTimer);
|
|
147
|
+
this.idleCheckTimer = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ── Outbox (send queue for offline buffering) ─────────────────────────
|
|
151
|
+
enqueueOutbox(type, frame) {
|
|
152
|
+
const item = {
|
|
153
|
+
frame: JSON.stringify(frame),
|
|
154
|
+
type,
|
|
155
|
+
enqueuedAt: Date.now(),
|
|
156
|
+
};
|
|
157
|
+
this.outbox.push(item);
|
|
158
|
+
if (this.outbox.length > MAX_OUTBOX_SIZE) {
|
|
159
|
+
const dropped = this.outbox.shift();
|
|
160
|
+
console.warn(`[nexus] Outbox full, dropping oldest ${dropped?.type} (age ${Math.round((Date.now() - (dropped?.enqueuedAt || 0)) / 1000)}s)`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
flushOutbox() {
|
|
164
|
+
if (this.outbox.length === 0)
|
|
165
|
+
return;
|
|
166
|
+
const items = this.outbox.splice(0);
|
|
167
|
+
console.log(`[nexus] Flushing outbox: ${items.length} queued messages`);
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
if (this.ws?.readyState === ws_1.default.OPEN) {
|
|
170
|
+
this.ws.send(item.frame);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Connection dropped again during flush — re-enqueue remaining
|
|
174
|
+
this.outbox.push(item);
|
|
175
|
+
console.warn(`[nexus] WS closed during outbox flush, ${this.outbox.length} items re-queued`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ── Message handling ──────────────────────────────────────────────────
|
|
111
181
|
handleMessage(data) {
|
|
112
182
|
let frame;
|
|
113
183
|
try {
|
|
@@ -127,6 +197,8 @@ class WSClient {
|
|
|
127
197
|
this.state.userId = connectedFrame.user_id;
|
|
128
198
|
}
|
|
129
199
|
console.log(`[nexus] WS connected: user_id=${this.state.userId} backlog_count=${JSON.stringify(connectedFrame.backlog_count || {})}`);
|
|
200
|
+
// Flush any messages queued during the disconnect
|
|
201
|
+
this.flushOutbox();
|
|
130
202
|
this.config.onConnected?.(connectedFrame);
|
|
131
203
|
break;
|
|
132
204
|
}
|
|
@@ -160,7 +232,14 @@ class WSClient {
|
|
|
160
232
|
handleClose(code, reason, myConnId) {
|
|
161
233
|
console.log(`[nexus] WS closed: code=${code} reason=${reason.toString() || 'none'} connId=${myConnId}/${this.reconnectState.connId}`);
|
|
162
234
|
this.state.connected = false;
|
|
235
|
+
this.stopIdleCheck();
|
|
163
236
|
(0, resume_store_1.flushResumeTokens)();
|
|
237
|
+
// Phase 3.3: Reset pendingApproval unless the close was due to pending approval
|
|
238
|
+
// (code 4003 is used by hub2d for PENDING_APPROVAL rejections)
|
|
239
|
+
if (this.state.pendingApproval && code !== 4003) {
|
|
240
|
+
this.state.pendingApproval = false;
|
|
241
|
+
(0, reconnect_1.resetReconnectDelay)(this.reconnectState);
|
|
242
|
+
}
|
|
164
243
|
if (!(0, reconnect_1.isStaleConnection)(this.reconnectState, myConnId)) {
|
|
165
244
|
this.scheduleReconnect();
|
|
166
245
|
}
|
|
@@ -191,35 +270,45 @@ class WSClient {
|
|
|
191
270
|
}
|
|
192
271
|
}
|
|
193
272
|
sendAck(roomId, eventId) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
273
|
+
const frame = (0, frames_1.buildAckFrame)(roomId, eventId);
|
|
274
|
+
if (this.ws?.readyState === ws_1.default.OPEN) {
|
|
275
|
+
this.ws.send(JSON.stringify(frame));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
this.enqueueOutbox('ack', frame);
|
|
279
|
+
}
|
|
197
280
|
}
|
|
198
281
|
sendReply(eventId, roomId, to, text, status) {
|
|
199
|
-
|
|
200
|
-
console.error(`[nexus] Cannot send reply, WS not open (event_id=${eventId})`);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
this.ws.send(JSON.stringify((0, frames_1.buildReplyFrame)({
|
|
282
|
+
const frame = (0, frames_1.buildReplyFrame)({
|
|
204
283
|
eventId,
|
|
205
284
|
roomId,
|
|
206
285
|
from: this.config.agentName,
|
|
207
286
|
to,
|
|
208
287
|
text,
|
|
209
288
|
status,
|
|
210
|
-
})
|
|
289
|
+
});
|
|
290
|
+
if (this.ws?.readyState === ws_1.default.OPEN) {
|
|
291
|
+
this.ws.send(JSON.stringify(frame));
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
this.enqueueOutbox('reply', frame);
|
|
295
|
+
console.warn(`[nexus] WS not open, queued reply (event_id=${eventId})`);
|
|
296
|
+
}
|
|
211
297
|
}
|
|
212
298
|
sendMessage(roomId, text, mentions, clientMsgId) {
|
|
213
|
-
|
|
214
|
-
console.error('[nexus] Cannot send message, WS not connected');
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
this.ws.send(JSON.stringify((0, frames_1.buildSendFrame)({
|
|
299
|
+
const frame = (0, frames_1.buildSendFrame)({
|
|
218
300
|
roomId,
|
|
219
301
|
text,
|
|
220
302
|
mentions,
|
|
221
303
|
clientMsgId,
|
|
222
|
-
})
|
|
304
|
+
});
|
|
305
|
+
if (this.ws?.readyState === ws_1.default.OPEN) {
|
|
306
|
+
this.ws.send(JSON.stringify(frame));
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
this.enqueueOutbox('send', frame);
|
|
310
|
+
console.warn('[nexus] WS not open, queued message');
|
|
311
|
+
}
|
|
223
312
|
}
|
|
224
313
|
getState() {
|
|
225
314
|
return { ...this.state };
|
|
@@ -230,11 +319,15 @@ class WSClient {
|
|
|
230
319
|
getUserId() {
|
|
231
320
|
return this.state.userId;
|
|
232
321
|
}
|
|
322
|
+
getOutboxSize() {
|
|
323
|
+
return this.outbox.length;
|
|
324
|
+
}
|
|
233
325
|
close() {
|
|
234
326
|
if (this.reconnectTimer) {
|
|
235
327
|
clearTimeout(this.reconnectTimer);
|
|
236
328
|
this.reconnectTimer = null;
|
|
237
329
|
}
|
|
330
|
+
this.stopIdleCheck();
|
|
238
331
|
if (this.ws) {
|
|
239
332
|
try {
|
|
240
333
|
this.ws.close();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_test_1 = require("node:test");
|
|
7
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
8
|
+
/**
|
|
9
|
+
* Unit tests for WSClient outbox queue and idle detection features.
|
|
10
|
+
*
|
|
11
|
+
* These test the data structures and logic without actual WebSocket connections.
|
|
12
|
+
*/
|
|
13
|
+
// ── Outbox queue logic ─────────────────────────────────────────────────
|
|
14
|
+
(0, node_test_1.describe)('outbox queue', () => {
|
|
15
|
+
const MAX_OUTBOX_SIZE = 100;
|
|
16
|
+
function createOutbox() {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
function enqueue(outbox, type, frame) {
|
|
20
|
+
const item = {
|
|
21
|
+
frame: JSON.stringify(frame),
|
|
22
|
+
type,
|
|
23
|
+
enqueuedAt: Date.now(),
|
|
24
|
+
};
|
|
25
|
+
outbox.push(item);
|
|
26
|
+
if (outbox.length > MAX_OUTBOX_SIZE) {
|
|
27
|
+
const dropped = outbox.shift();
|
|
28
|
+
console.warn(`Outbox full, dropping oldest ${dropped?.type}`);
|
|
29
|
+
}
|
|
30
|
+
return outbox;
|
|
31
|
+
}
|
|
32
|
+
(0, node_test_1.it)('queues items when WS is not connected', () => {
|
|
33
|
+
const outbox = createOutbox();
|
|
34
|
+
enqueue(outbox, 'reply', { type: 'reply', text: 'hello' });
|
|
35
|
+
enqueue(outbox, 'ack', { type: 'ack', room_id: 'general', event_id: '123' });
|
|
36
|
+
strict_1.default.equal(outbox.length, 2);
|
|
37
|
+
strict_1.default.equal(outbox[0].type, 'reply');
|
|
38
|
+
strict_1.default.equal(outbox[1].type, 'ack');
|
|
39
|
+
});
|
|
40
|
+
(0, node_test_1.it)('drops oldest when queue exceeds max size', () => {
|
|
41
|
+
const outbox = createOutbox();
|
|
42
|
+
// Fill beyond max
|
|
43
|
+
for (let i = 0; i < MAX_OUTBOX_SIZE + 10; i++) {
|
|
44
|
+
enqueue(outbox, 'reply', { type: 'reply', text: `msg-${i}` });
|
|
45
|
+
}
|
|
46
|
+
strict_1.default.equal(outbox.length, MAX_OUTBOX_SIZE);
|
|
47
|
+
// First item should be msg-10 (first 10 were dropped)
|
|
48
|
+
const first = JSON.parse(outbox[0].frame);
|
|
49
|
+
strict_1.default.equal(first.text, 'msg-10');
|
|
50
|
+
});
|
|
51
|
+
(0, node_test_1.it)('flush drains the queue', () => {
|
|
52
|
+
const outbox = createOutbox();
|
|
53
|
+
enqueue(outbox, 'reply', { type: 'reply', text: 'hello' });
|
|
54
|
+
enqueue(outbox, 'ack', { type: 'ack', room_id: 'general', event_id: '123' });
|
|
55
|
+
const flushed = outbox.splice(0);
|
|
56
|
+
strict_1.default.equal(flushed.length, 2);
|
|
57
|
+
strict_1.default.equal(outbox.length, 0);
|
|
58
|
+
});
|
|
59
|
+
(0, node_test_1.it)('preserves frame serialization', () => {
|
|
60
|
+
const outbox = createOutbox();
|
|
61
|
+
const original = { type: 'reply', event_id: '123-0', room_id: 'general', from: 'cortana', to: 'boss', text: 'test reply', status: 'done' };
|
|
62
|
+
enqueue(outbox, 'reply', original);
|
|
63
|
+
const parsed = JSON.parse(outbox[0].frame);
|
|
64
|
+
strict_1.default.deepEqual(parsed, original);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
// ── Idle detection logic ──────────────────────────────────────────────
|
|
68
|
+
(0, node_test_1.describe)('idle detection', () => {
|
|
69
|
+
const IDLE_TIMEOUT_MS = 90000;
|
|
70
|
+
(0, node_test_1.it)('detects stale connection when no frames received', () => {
|
|
71
|
+
const lastFrameAt = Date.now() - 100000; // 100s ago, over 90s threshold
|
|
72
|
+
const idle = Date.now() - lastFrameAt;
|
|
73
|
+
strict_1.default.ok(idle > IDLE_TIMEOUT_MS, 'Should be over idle threshold');
|
|
74
|
+
});
|
|
75
|
+
(0, node_test_1.it)('does not flag fresh connection as stale', () => {
|
|
76
|
+
const lastFrameAt = Date.now() - 30000; // 30s ago, under 90s threshold
|
|
77
|
+
const idle = Date.now() - lastFrameAt;
|
|
78
|
+
strict_1.default.ok(idle < IDLE_TIMEOUT_MS, 'Should be under idle threshold');
|
|
79
|
+
});
|
|
80
|
+
(0, node_test_1.it)('reset pendingApproval on non-pending close', () => {
|
|
81
|
+
let pendingApproval = true;
|
|
82
|
+
const closeCode = 1000; // normal close, not 4003
|
|
83
|
+
if (pendingApproval && closeCode !== 4003) {
|
|
84
|
+
pendingApproval = false;
|
|
85
|
+
}
|
|
86
|
+
strict_1.default.equal(pendingApproval, false, 'pendingApproval should be reset on normal close');
|
|
87
|
+
});
|
|
88
|
+
(0, node_test_1.it)('keep pendingApproval on 4003 close code', () => {
|
|
89
|
+
let pendingApproval = true;
|
|
90
|
+
const closeCode = 4003; // PENDING_APPROVAL code
|
|
91
|
+
if (pendingApproval && closeCode !== 4003) {
|
|
92
|
+
pendingApproval = false;
|
|
93
|
+
}
|
|
94
|
+
strict_1.default.equal(pendingApproval, true, 'pendingApproval should stay on PENDING_APPROVAL close');
|
|
95
|
+
});
|
|
96
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexus-channel",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
4
4
|
"description": "Nexus Hub 2.0 channel plugin for OpenClaw — enables agents to connect to Nexus Hub as a channel, with A2A dispatch, room summary, and Control Plane management.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"openclaw": "*"
|
|
28
28
|
},
|
|
29
|
-
"bundledDependencies": [
|
|
29
|
+
"bundledDependencies": [
|
|
30
|
+
"ws"
|
|
31
|
+
],
|
|
30
32
|
"openclaw": {
|
|
31
33
|
"extensions": [
|
|
32
34
|
"./dist/index.js"
|