@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13
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/INSTALL.md +64 -0
- package/README.md +121 -19
- package/dist/index.js +10 -19
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +78 -10
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +25 -156
- package/dist/src/channel.setup.js +120 -0
- package/dist/src/client.js +37 -41
- package/dist/src/config.js +75 -17
- package/dist/src/inbound.js +79 -61
- package/dist/src/login.runtime.js +84 -19
- package/dist/src/media-runtime.js +8 -8
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +410 -26
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -7
- package/dist/src/reply-dispatcher.js +157 -54
- package/dist/src/runtime.js +795 -119
- package/dist/src/storage.js +689 -0
- package/dist/src/tools-schema.js +98 -16
- package/dist/src/tools.js +422 -135
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +10 -22
- package/openclaw.plugin.json +37 -2
- package/package.json +17 -4
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +88 -0
- package/src/api-client.test.ts +274 -14
- package/src/api-client.ts +138 -23
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +90 -4
- package/src/buffered-stream.test.ts +14 -12
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +269 -60
- package/src/channel.setup.ts +146 -0
- package/src/channel.test.ts +130 -24
- package/src/channel.ts +30 -186
- package/src/client.test.ts +197 -11
- package/src/client.ts +50 -57
- package/src/config.test.ts +108 -6
- package/src/config.ts +95 -24
- package/src/inbound.test.ts +288 -37
- package/src/inbound.ts +96 -84
- package/src/login.runtime.test.ts +347 -13
- package/src/login.runtime.ts +105 -23
- package/src/manifest.test.ts +146 -74
- package/src/media-runtime.test.ts +57 -2
- package/src/media-runtime.ts +26 -17
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +694 -73
- package/src/outbound.ts +484 -31
- package/src/plugin-entry.test.ts +1 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +1 -6
- package/src/protocol.ts +2 -7
- package/src/reply-dispatcher.test.ts +819 -119
- package/src/reply-dispatcher.ts +202 -60
- package/src/runtime.test.ts +2120 -41
- package/src/runtime.ts +935 -142
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +793 -0
- package/src/storage.ts +1095 -0
- package/src/streaming.test.ts +9 -8
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +148 -20
- package/src/tools.test.ts +377 -50
- package/src/tools.ts +574 -154
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
- package/skills/clawchat-account-tools/SKILL.md +0 -26
- package/skills/clawchat-activate/SKILL.md +0 -47
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createAlignedWsQueue,
|
|
4
|
+
createProtocolControlHandler,
|
|
5
|
+
createReconnectTracker,
|
|
6
|
+
} from "./ws-alignment.ts";
|
|
7
|
+
|
|
8
|
+
describe("aligned websocket queue", () => {
|
|
9
|
+
it("drops oldest frame when queue is full", () => {
|
|
10
|
+
const logs: string[] = [];
|
|
11
|
+
const queue = createAlignedWsQueue({ accountId: "default", log: (msg) => logs.push(msg), maxSize: 2 });
|
|
12
|
+
|
|
13
|
+
queue.enqueue({ eventName: "typing.update", traceId: "a", chatId: "c", wire: "a" });
|
|
14
|
+
queue.enqueue({ eventName: "typing.update", traceId: "b", chatId: "c", wire: "b" });
|
|
15
|
+
queue.enqueue({ eventName: "typing.update", traceId: "c", chatId: "c", wire: "c" });
|
|
16
|
+
|
|
17
|
+
expect(queue.snapshot().map((item) => item.traceId)).toEqual(["b", "c"]);
|
|
18
|
+
expect(logs.some((line) => line.includes("event=send_queue_drop"))).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps failed flush frame at the head", () => {
|
|
22
|
+
const queue = createAlignedWsQueue({ accountId: "default", log: () => {}, maxSize: 128 });
|
|
23
|
+
queue.enqueue({ eventName: "message.reply", traceId: "a", chatId: "c", wire: "a" });
|
|
24
|
+
queue.enqueue({ eventName: "message.reply", traceId: "b", chatId: "c", wire: "b" });
|
|
25
|
+
|
|
26
|
+
expect(() => queue.flush(() => { throw new Error("socket closed"); })).toThrow("socket closed");
|
|
27
|
+
expect(queue.snapshot().map((item) => item.traceId)).toEqual(["a", "b"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resets reconnect_count only after ready is stable for 5000ms", () => {
|
|
31
|
+
vi.useFakeTimers();
|
|
32
|
+
const logs: string[] = [];
|
|
33
|
+
const tracker = createReconnectTracker({ accountId: "default", log: (msg) => logs.push(msg) });
|
|
34
|
+
|
|
35
|
+
expect(tracker.connectStart()).toMatchObject({ attempt: 1, reconnectCount: 0 });
|
|
36
|
+
tracker.scheduleReconnect("connection_lost");
|
|
37
|
+
expect(tracker.connectStart()).toMatchObject({ attempt: 2, reconnectCount: 1 });
|
|
38
|
+
tracker.markReady();
|
|
39
|
+
vi.advanceTimersByTime(4999);
|
|
40
|
+
expect(tracker.snapshot().reconnectCount).toBe(1);
|
|
41
|
+
vi.advanceTimersByTime(1);
|
|
42
|
+
expect(tracker.snapshot().reconnectCount).toBe(0);
|
|
43
|
+
expect(logs).toContain(
|
|
44
|
+
"clawchat.ws event=reconnect_backoff_reset account_id=default attempt=2 reconnect_count=0 state=ready action=reset stable_ms=5000",
|
|
45
|
+
);
|
|
46
|
+
vi.useRealTimers();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("answers JSON ping and logs JSON pong as protocol control", () => {
|
|
50
|
+
const logs: string[] = [];
|
|
51
|
+
const sent: string[] = [];
|
|
52
|
+
const handler = createProtocolControlHandler({
|
|
53
|
+
accountId: "default",
|
|
54
|
+
log: (msg) => logs.push(msg),
|
|
55
|
+
send: (wire) => sent.push(wire),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
handler.handleInbound({
|
|
60
|
+
version: "2",
|
|
61
|
+
event: "ping",
|
|
62
|
+
trace_id: "trace-ping",
|
|
63
|
+
emitted_at: Date.now(),
|
|
64
|
+
payload: {},
|
|
65
|
+
}),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
expect(JSON.parse(sent[0]!)).toMatchObject({ event: "pong", trace_id: "trace-ping" });
|
|
68
|
+
expect(logs).toContain(
|
|
69
|
+
"clawchat.ws event=protocol_ping_received account_id=default attempt=1 reconnect_count=0 state=ready action=send_pong trace_id=trace-ping",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
handler.handleInbound({
|
|
74
|
+
version: "2",
|
|
75
|
+
event: "pong",
|
|
76
|
+
trace_id: "trace-pong",
|
|
77
|
+
emitted_at: Date.now(),
|
|
78
|
+
payload: {},
|
|
79
|
+
}),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
expect(logs).toContain(
|
|
82
|
+
"clawchat.ws event=protocol_pong_received account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-pong",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("logs heartbeat timeout and schedules reconnect", () => {
|
|
87
|
+
const logs: string[] = [];
|
|
88
|
+
const reconnects: string[] = [];
|
|
89
|
+
const handler = createProtocolControlHandler({
|
|
90
|
+
accountId: "default",
|
|
91
|
+
log: (msg) => logs.push(msg),
|
|
92
|
+
send: () => {},
|
|
93
|
+
scheduleReconnect: (reason) => reconnects.push(reason),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
handler.heartbeatTimeout(10000);
|
|
97
|
+
|
|
98
|
+
expect(logs).toContain(
|
|
99
|
+
"clawchat.ws event=heartbeat_timeout account_id=default attempt=1 reconnect_count=0 state=ready action=reconnect timeout_ms=10000",
|
|
100
|
+
);
|
|
101
|
+
expect(reconnects).toEqual(["heartbeat_timeout"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { formatWsLog } from "./ws-log.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_WS_QUEUE_SIZE = 128;
|
|
4
|
+
|
|
5
|
+
export interface AlignedWsQueueItem {
|
|
6
|
+
eventName: string;
|
|
7
|
+
traceId: string;
|
|
8
|
+
chatId?: string;
|
|
9
|
+
wire: string;
|
|
10
|
+
onWrite?: () => void;
|
|
11
|
+
onDrop?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CreateAlignedWsQueueOptions {
|
|
15
|
+
accountId: string;
|
|
16
|
+
log: (msg: string) => void;
|
|
17
|
+
maxSize?: number;
|
|
18
|
+
attempt?: number;
|
|
19
|
+
reconnectCount?: number;
|
|
20
|
+
state?: string;
|
|
21
|
+
context?: () => WsLogContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WsLogContext {
|
|
25
|
+
attempt: number;
|
|
26
|
+
reconnectCount: number;
|
|
27
|
+
state: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createAlignedWsQueue(options: CreateAlignedWsQueueOptions) {
|
|
31
|
+
const maxSize = options.maxSize ?? MAX_WS_QUEUE_SIZE;
|
|
32
|
+
const queue: AlignedWsQueueItem[] = [];
|
|
33
|
+
const context = (): WsLogContext =>
|
|
34
|
+
options.context?.() ?? {
|
|
35
|
+
attempt: options.attempt ?? 1,
|
|
36
|
+
reconnectCount: options.reconnectCount ?? 0,
|
|
37
|
+
state: options.state ?? "ready",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const logFrame = (
|
|
41
|
+
event: string,
|
|
42
|
+
action: string,
|
|
43
|
+
item: AlignedWsQueueItem,
|
|
44
|
+
fields: Array<[string, string | number | boolean | null | undefined]> = [],
|
|
45
|
+
logState?: string,
|
|
46
|
+
) => {
|
|
47
|
+
const current = context();
|
|
48
|
+
options.log(
|
|
49
|
+
formatWsLog({
|
|
50
|
+
event,
|
|
51
|
+
accountId: options.accountId,
|
|
52
|
+
attempt: current.attempt,
|
|
53
|
+
reconnectCount: current.reconnectCount,
|
|
54
|
+
state: logState ?? current.state,
|
|
55
|
+
action,
|
|
56
|
+
fields: [
|
|
57
|
+
["event_name", item.eventName],
|
|
58
|
+
["trace_id", item.traceId],
|
|
59
|
+
["chat_id", item.chatId],
|
|
60
|
+
...fields,
|
|
61
|
+
],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
enqueue(item: AlignedWsQueueItem) {
|
|
68
|
+
if (queue.length >= maxSize) {
|
|
69
|
+
const dropped = queue.shift();
|
|
70
|
+
if (dropped) {
|
|
71
|
+
logFrame(
|
|
72
|
+
"send_queue_drop",
|
|
73
|
+
"drop_oldest",
|
|
74
|
+
dropped,
|
|
75
|
+
[
|
|
76
|
+
["queue_size", queue.length],
|
|
77
|
+
["queue_max", maxSize],
|
|
78
|
+
],
|
|
79
|
+
context().state,
|
|
80
|
+
);
|
|
81
|
+
dropped.onDrop?.();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
queue.push(item);
|
|
85
|
+
logFrame("send_queued", "queue", item, [["queue_size", queue.length]]);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
flush(write: (wire: string) => void) {
|
|
89
|
+
while (queue.length > 0) {
|
|
90
|
+
const item = queue[0]!;
|
|
91
|
+
try {
|
|
92
|
+
write(item.wire);
|
|
93
|
+
queue.shift();
|
|
94
|
+
item.onWrite?.();
|
|
95
|
+
logFrame("send_flush", "send", item, [["remaining", queue.length]], "ready");
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logFrame("send_failed", "requeue_reconnect", item, [["queue_size", queue.length]]);
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
remove(item: AlignedWsQueueItem) {
|
|
104
|
+
const index = queue.indexOf(item);
|
|
105
|
+
if (index < 0) return false;
|
|
106
|
+
queue.splice(index, 1);
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
snapshot() {
|
|
111
|
+
return [...queue];
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CreateReconnectTrackerOptions {
|
|
117
|
+
accountId: string;
|
|
118
|
+
log: (msg: string) => void;
|
|
119
|
+
stableResetMs?: number;
|
|
120
|
+
initialDelayMs?: number;
|
|
121
|
+
maxDelayMs?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createReconnectTracker(options: CreateReconnectTrackerOptions) {
|
|
125
|
+
const stableResetMs = options.stableResetMs ?? 5000;
|
|
126
|
+
let attempt = 0;
|
|
127
|
+
let reconnectCount = 0;
|
|
128
|
+
let state = "idle";
|
|
129
|
+
let stableResetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
130
|
+
|
|
131
|
+
const clearStableReset = () => {
|
|
132
|
+
if (!stableResetTimer) return;
|
|
133
|
+
clearTimeout(stableResetTimer);
|
|
134
|
+
stableResetTimer = undefined;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
connectStart() {
|
|
139
|
+
clearStableReset();
|
|
140
|
+
attempt += 1;
|
|
141
|
+
state = "connecting";
|
|
142
|
+
return { attempt, reconnectCount };
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
scheduleReconnect(
|
|
146
|
+
reason: string,
|
|
147
|
+
details: { delayMs?: number; maxDelayMs?: number } = {},
|
|
148
|
+
) {
|
|
149
|
+
clearStableReset();
|
|
150
|
+
reconnectCount += 1;
|
|
151
|
+
state = "reconnecting";
|
|
152
|
+
if (details.delayMs !== undefined || details.maxDelayMs !== undefined) {
|
|
153
|
+
options.log(
|
|
154
|
+
formatWsLog({
|
|
155
|
+
event: "reconnect_scheduled",
|
|
156
|
+
accountId: options.accountId,
|
|
157
|
+
attempt,
|
|
158
|
+
reconnectCount,
|
|
159
|
+
state: "reconnecting",
|
|
160
|
+
action: "wait",
|
|
161
|
+
fields: [
|
|
162
|
+
["delay_ms", details.delayMs],
|
|
163
|
+
["max_delay_ms", details.maxDelayMs ?? options.maxDelayMs],
|
|
164
|
+
["reason", reason],
|
|
165
|
+
],
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return { attempt, reconnectCount };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
markReady() {
|
|
173
|
+
clearStableReset();
|
|
174
|
+
state = "ready";
|
|
175
|
+
stableResetTimer = setTimeout(() => {
|
|
176
|
+
reconnectCount = 0;
|
|
177
|
+
options.log(
|
|
178
|
+
formatWsLog({
|
|
179
|
+
event: "reconnect_backoff_reset",
|
|
180
|
+
accountId: options.accountId,
|
|
181
|
+
attempt,
|
|
182
|
+
reconnectCount,
|
|
183
|
+
state: "ready",
|
|
184
|
+
action: "reset",
|
|
185
|
+
fields: [["stable_ms", stableResetMs]],
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
stableResetTimer = undefined;
|
|
189
|
+
}, stableResetMs);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
markClosed() {
|
|
193
|
+
clearStableReset();
|
|
194
|
+
state = "closed";
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
snapshot() {
|
|
198
|
+
return { attempt, reconnectCount, state };
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ProtocolControlEnvelope {
|
|
204
|
+
version?: string;
|
|
205
|
+
event: string;
|
|
206
|
+
trace_id?: string;
|
|
207
|
+
emitted_at?: number;
|
|
208
|
+
payload?: unknown;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface CreateProtocolControlHandlerOptions {
|
|
212
|
+
accountId: string;
|
|
213
|
+
log: (msg: string) => void;
|
|
214
|
+
send: (wire: string) => void;
|
|
215
|
+
scheduleReconnect?: (reason: string) => void;
|
|
216
|
+
attempt?: number;
|
|
217
|
+
reconnectCount?: number;
|
|
218
|
+
state?: string;
|
|
219
|
+
context?: () => WsLogContext;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createProtocolControlHandler(options: CreateProtocolControlHandlerOptions) {
|
|
223
|
+
const context = (): WsLogContext =>
|
|
224
|
+
options.context?.() ?? {
|
|
225
|
+
attempt: options.attempt ?? 1,
|
|
226
|
+
reconnectCount: options.reconnectCount ?? 0,
|
|
227
|
+
state: options.state ?? "ready",
|
|
228
|
+
};
|
|
229
|
+
const logControl = (
|
|
230
|
+
event: string,
|
|
231
|
+
action: string,
|
|
232
|
+
fields: Array<[string, string | number | boolean | null | undefined]>,
|
|
233
|
+
) => {
|
|
234
|
+
const current = context();
|
|
235
|
+
options.log(
|
|
236
|
+
formatWsLog({
|
|
237
|
+
event,
|
|
238
|
+
accountId: options.accountId,
|
|
239
|
+
attempt: current.attempt,
|
|
240
|
+
reconnectCount: current.reconnectCount,
|
|
241
|
+
state: current.state,
|
|
242
|
+
action,
|
|
243
|
+
fields,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
handleInbound(env: ProtocolControlEnvelope): boolean {
|
|
250
|
+
if (env.event === "ping") {
|
|
251
|
+
logControl("protocol_ping_received", "send_pong", [["trace_id", env.trace_id]]);
|
|
252
|
+
options.send(
|
|
253
|
+
JSON.stringify({
|
|
254
|
+
version: "2",
|
|
255
|
+
event: "pong",
|
|
256
|
+
trace_id: env.trace_id ?? "-",
|
|
257
|
+
emitted_at: Date.now(),
|
|
258
|
+
payload: {},
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (env.event === "pong") {
|
|
264
|
+
logControl("protocol_pong_received", "ignore", [["trace_id", env.trace_id]]);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
heartbeatTimeout(timeoutMs: number) {
|
|
271
|
+
logControl("heartbeat_timeout", "reconnect", [["timeout_ms", timeoutMs]]);
|
|
272
|
+
options.scheduleReconnect?.("heartbeat_timeout");
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|