@yahaha-studio/kichi-forwarder 0.0.1-alpha.49 → 0.0.1-alpha.51
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/config/kichi-config.json +72 -0
- package/index.ts +124 -532
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +57 -287
- package/skills/kichi-forwarder/references/error.md +2 -2
- package/skills/kichi-forwarder/references/heartbeat.md +1 -1
- package/skills/kichi-forwarder/references/install.md +41 -71
- package/src/config.ts +4 -4
- package/src/service.ts +344 -259
- package/src/types.ts +9 -24
- package/config/album-config.json +0 -509
package/src/service.ts
CHANGED
|
@@ -5,31 +5,31 @@ import * as path from "path";
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import type { Logger } from "openclaw/plugin-sdk";
|
|
7
7
|
import type {
|
|
8
|
-
AvatarCommand,
|
|
9
|
-
AvatarCommandPayload,
|
|
10
8
|
ClockAction,
|
|
11
9
|
ClockConfig,
|
|
12
10
|
ClockPayload,
|
|
13
11
|
CreateMusicAlbumPayload,
|
|
12
|
+
CreateNotesBoardNotePayload,
|
|
14
13
|
HookNotifyPayload,
|
|
15
14
|
HookNotifyType,
|
|
16
15
|
JoinAckPayload,
|
|
17
16
|
JoinPayload,
|
|
18
17
|
KichiConnectionStatus,
|
|
19
|
-
CreateNotesBoardNotePayload,
|
|
20
18
|
KichiForwarderConfig,
|
|
21
19
|
KichiIdentity,
|
|
20
|
+
KichiState,
|
|
22
21
|
LeaveAckPayload,
|
|
23
22
|
PoseType,
|
|
24
23
|
QueryStatusPayload,
|
|
25
24
|
QueryStatusResultPayload,
|
|
26
25
|
StatusPayload,
|
|
27
|
-
WorkspaceScreenPayload,
|
|
28
26
|
} from "./types.js";
|
|
29
27
|
|
|
30
|
-
const
|
|
31
|
-
const
|
|
28
|
+
const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
29
|
+
const HOSTS_DIR = path.join(KICHI_WORLD_DIR, "hosts");
|
|
30
|
+
const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
|
|
32
31
|
const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
32
|
+
const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
33
33
|
|
|
34
34
|
type AckFailureResult = {
|
|
35
35
|
success: false;
|
|
@@ -56,6 +56,7 @@ export class KichiForwarderService {
|
|
|
56
56
|
private stopped = false;
|
|
57
57
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
58
58
|
private identity: KichiIdentity | null = null;
|
|
59
|
+
private host: string;
|
|
59
60
|
private joinResolve: ((result: JoinResult) => void) | null = null;
|
|
60
61
|
private pendingRequests = new Map<
|
|
61
62
|
string,
|
|
@@ -67,9 +68,12 @@ export class KichiForwarderService {
|
|
|
67
68
|
}
|
|
68
69
|
>();
|
|
69
70
|
|
|
70
|
-
constructor(private config: KichiForwarderConfig, private logger: Logger) {
|
|
71
|
+
constructor(private config: KichiForwarderConfig, private logger: Logger) {
|
|
72
|
+
this.host = config.defaultHost;
|
|
73
|
+
}
|
|
71
74
|
|
|
72
75
|
async start(): Promise<void> {
|
|
76
|
+
this.host = this.loadCurrentHost();
|
|
73
77
|
this.identity = this.loadIdentity();
|
|
74
78
|
this.stopped = false;
|
|
75
79
|
this.connect();
|
|
@@ -77,10 +81,24 @@ export class KichiForwarderService {
|
|
|
77
81
|
|
|
78
82
|
async stop(): Promise<void> {
|
|
79
83
|
this.stopped = true;
|
|
80
|
-
|
|
84
|
+
this.clearReconnectTimeout();
|
|
81
85
|
this.rejectPendingRequests("Kichi websocket stopped");
|
|
82
|
-
this.
|
|
83
|
-
this.
|
|
86
|
+
this.failPendingJoin("Kichi websocket stopped");
|
|
87
|
+
this.closeSocket();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async switchHost(host: string): Promise<KichiConnectionStatus> {
|
|
91
|
+
this.persistCurrentHost(host);
|
|
92
|
+
this.host = host;
|
|
93
|
+
this.identity = this.loadIdentity();
|
|
94
|
+
this.clearReconnectTimeout();
|
|
95
|
+
this.rejectPendingRequests(`Kichi websocket switched to ${host}`);
|
|
96
|
+
this.failPendingJoin(`Kichi websocket switched to ${host}`);
|
|
97
|
+
this.closeSocket();
|
|
98
|
+
if (!this.stopped) {
|
|
99
|
+
this.connect();
|
|
100
|
+
}
|
|
101
|
+
return this.getConnectionStatus();
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
async join(
|
|
@@ -91,6 +109,7 @@ export class KichiForwarderService {
|
|
|
91
109
|
): Promise<JoinResult> {
|
|
92
110
|
return new Promise((resolve) => {
|
|
93
111
|
this.identity = { avatarId };
|
|
112
|
+
this.saveIdentity();
|
|
94
113
|
this.joinResolve = resolve;
|
|
95
114
|
const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags };
|
|
96
115
|
const sendJoin = () => this.ws?.send(JSON.stringify(payload));
|
|
@@ -108,15 +127,253 @@ export class KichiForwarderService {
|
|
|
108
127
|
});
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
sendStatus(poseType: PoseType | "", action: string, bubble: string, log: string): void {
|
|
131
|
+
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
|
|
132
|
+
const payload: StatusPayload = {
|
|
133
|
+
type: "status",
|
|
134
|
+
avatarId: this.identity.avatarId,
|
|
135
|
+
authKey: this.identity.authKey,
|
|
136
|
+
poseType,
|
|
137
|
+
action,
|
|
138
|
+
bubble,
|
|
139
|
+
log,
|
|
140
|
+
};
|
|
141
|
+
this.ws.send(JSON.stringify(payload));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
sendHookNotify(hookType: HookNotifyType, bubble: string): void {
|
|
145
|
+
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
|
|
146
|
+
const payload: HookNotifyPayload = {
|
|
147
|
+
type: hookType,
|
|
148
|
+
avatarId: this.identity.avatarId,
|
|
149
|
+
authKey: this.identity.authKey,
|
|
150
|
+
bubble,
|
|
151
|
+
};
|
|
152
|
+
this.ws.send(JSON.stringify(payload));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
|
|
156
|
+
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
157
|
+
if (action === "set" && !clock) return false;
|
|
158
|
+
|
|
159
|
+
const basePayload = {
|
|
160
|
+
type: "clock" as const,
|
|
161
|
+
avatarId: this.identity.avatarId,
|
|
162
|
+
authKey: this.identity.authKey,
|
|
163
|
+
...(requestId ? { requestId } : {}),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const payload: ClockPayload =
|
|
167
|
+
action === "set"
|
|
168
|
+
? {
|
|
169
|
+
...basePayload,
|
|
170
|
+
action,
|
|
171
|
+
clock,
|
|
172
|
+
}
|
|
173
|
+
: {
|
|
174
|
+
...basePayload,
|
|
175
|
+
action,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.ws.send(JSON.stringify(payload));
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async queryStatus(requestId?: string): Promise<QueryStatusResultPayload> {
|
|
183
|
+
const identity = this.requireIdentity();
|
|
184
|
+
if (!identity) {
|
|
185
|
+
throw new Error("Missing Kichi identity");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const payload: QueryStatusPayload = {
|
|
189
|
+
type: "query_status",
|
|
190
|
+
requestId: requestId?.trim() || randomUUID(),
|
|
191
|
+
avatarId: identity.avatarId,
|
|
192
|
+
authKey: identity.authKey,
|
|
193
|
+
};
|
|
194
|
+
return this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
createNotesBoardNote(propId: string, data: string): void {
|
|
198
|
+
const identity = this.requireIdentity();
|
|
199
|
+
if (!identity) {
|
|
200
|
+
throw new Error("Missing Kichi identity");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
|
|
204
|
+
throw new Error(`Note content must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
208
|
+
throw new Error("Kichi websocket is not connected");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const payload: CreateNotesBoardNotePayload = {
|
|
212
|
+
type: "create_notes_board_note",
|
|
213
|
+
avatarId: identity.avatarId,
|
|
214
|
+
authKey: identity.authKey,
|
|
215
|
+
propId,
|
|
216
|
+
data,
|
|
217
|
+
};
|
|
218
|
+
this.ws.send(JSON.stringify(payload));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
createMusicAlbum(albumTitle: string, musicTitles: string[], requestId?: string): string {
|
|
222
|
+
const identity = this.requireIdentity();
|
|
223
|
+
if (!identity) {
|
|
224
|
+
throw new Error("Missing Kichi identity");
|
|
225
|
+
}
|
|
226
|
+
if (!albumTitle.trim()) {
|
|
227
|
+
throw new Error("albumTitle is required");
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(musicTitles) || musicTitles.length === 0) {
|
|
230
|
+
throw new Error("musicTitles must contain at least one track title");
|
|
231
|
+
}
|
|
232
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
233
|
+
throw new Error("Kichi websocket is not connected");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const normalizedRequestId = requestId?.trim() || randomUUID();
|
|
237
|
+
const payload: CreateMusicAlbumPayload = {
|
|
238
|
+
type: "create_music_album",
|
|
239
|
+
requestId: normalizedRequestId,
|
|
240
|
+
avatarId: identity.avatarId,
|
|
241
|
+
authKey: identity.authKey,
|
|
242
|
+
albumTitle: albumTitle.trim(),
|
|
243
|
+
musicTitles,
|
|
244
|
+
};
|
|
245
|
+
this.ws.send(JSON.stringify(payload));
|
|
246
|
+
return normalizedRequestId;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
|
|
250
|
+
|
|
251
|
+
hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
|
|
252
|
+
|
|
253
|
+
getCurrentHost(): string {
|
|
254
|
+
return this.host;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
getIdentityPath(): string {
|
|
258
|
+
return path.join(this.getIdentityDir(), "identity.json");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
readSavedAvatarId(): string | null {
|
|
262
|
+
return this.loadIdentity()?.avatarId ?? null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
requestRejoin(): { accepted: boolean; mode: "sent" | "waiting_open" | "reconnecting" | "unavailable"; message: string } {
|
|
266
|
+
if (!this.identity?.avatarId || !this.identity?.authKey) {
|
|
267
|
+
return {
|
|
268
|
+
accepted: false,
|
|
269
|
+
mode: "unavailable",
|
|
270
|
+
message: "Missing authKey. Run kichi_join first.",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
275
|
+
const sent = this.sendRejoinPayload();
|
|
276
|
+
return {
|
|
277
|
+
accepted: sent,
|
|
278
|
+
mode: sent ? "sent" : "unavailable",
|
|
279
|
+
message: sent ? "Rejoin payload sent." : "Unable to send rejoin payload.",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
284
|
+
return {
|
|
285
|
+
accepted: true,
|
|
286
|
+
mode: "waiting_open",
|
|
287
|
+
message: "WebSocket is connecting. Rejoin will be sent automatically on open.",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (this.stopped) {
|
|
292
|
+
return {
|
|
293
|
+
accepted: false,
|
|
294
|
+
mode: "unavailable",
|
|
295
|
+
message: "Service is not running.",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.clearReconnectTimeout();
|
|
300
|
+
this.connect();
|
|
301
|
+
return {
|
|
302
|
+
accepted: true,
|
|
303
|
+
mode: "reconnecting",
|
|
304
|
+
message: "Reconnect started. Rejoin will be sent automatically on open.",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
getConnectionStatus(): KichiConnectionStatus {
|
|
309
|
+
return {
|
|
310
|
+
host: this.host,
|
|
311
|
+
wsUrl: this.getWsUrl(),
|
|
312
|
+
identityPath: this.getIdentityPath(),
|
|
313
|
+
connected: this.isConnected(),
|
|
314
|
+
websocketState: this.getWebsocketState(),
|
|
315
|
+
hasIdentity: !!this.identity?.avatarId,
|
|
316
|
+
avatarId: this.identity?.avatarId,
|
|
317
|
+
hasAuthKey: !!this.identity?.authKey,
|
|
318
|
+
pendingRequestCount: this.pendingRequests.size,
|
|
319
|
+
reconnectScheduled: !!this.reconnectTimeout,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async leave(): Promise<LeaveResult> {
|
|
324
|
+
if (!this.identity?.avatarId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
|
|
325
|
+
return { success: false, error: "Failed or not connected" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return new Promise((resolve) => {
|
|
329
|
+
const handler = (data: WebSocket.Data) => {
|
|
330
|
+
try {
|
|
331
|
+
const msg = JSON.parse(data.toString());
|
|
332
|
+
if (msg.type === "leave_ack") {
|
|
333
|
+
this.ws?.off("message", handler);
|
|
334
|
+
const leaveAck = msg as LeaveAckPayload;
|
|
335
|
+
if (leaveAck.success === false) {
|
|
336
|
+
resolve(this.buildAckFailure(leaveAck, "Leave failed"));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.clearAuthKey();
|
|
340
|
+
resolve({ success: true });
|
|
341
|
+
}
|
|
342
|
+
} catch (e) {
|
|
343
|
+
this.logger.warn(`Failed to parse leave response: ${e}`);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
this.ws!.on("message", handler);
|
|
347
|
+
this.ws!.send(
|
|
348
|
+
JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
|
|
349
|
+
);
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
this.ws?.off("message", handler);
|
|
352
|
+
resolve({ success: false, error: "Timed out waiting for leave_ack" });
|
|
353
|
+
}, 10000);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
111
357
|
private connect(): void {
|
|
112
358
|
if (this.stopped) return;
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
|
|
359
|
+
|
|
360
|
+
const wsUrl = this.getWsUrl();
|
|
361
|
+
const ws = new WebSocket(wsUrl);
|
|
362
|
+
this.ws = ws;
|
|
363
|
+
|
|
364
|
+
ws.on("open", () => {
|
|
365
|
+
if (this.ws !== ws) return;
|
|
366
|
+
this.logger.info(`Connected to ${wsUrl} (${this.host})`);
|
|
116
367
|
this.sendRejoinPayload();
|
|
117
368
|
});
|
|
118
|
-
|
|
119
|
-
|
|
369
|
+
|
|
370
|
+
ws.on("message", (data) => {
|
|
371
|
+
if (this.ws !== ws) return;
|
|
372
|
+
this.handleMessage(data.toString());
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
ws.on("close", () => {
|
|
376
|
+
if (this.ws !== ws) return;
|
|
120
377
|
this.ws = null;
|
|
121
378
|
this.rejectPendingRequests("Kichi websocket closed");
|
|
122
379
|
if (!this.stopped) {
|
|
@@ -126,7 +383,8 @@ export class KichiForwarderService {
|
|
|
126
383
|
}, 2000);
|
|
127
384
|
}
|
|
128
385
|
});
|
|
129
|
-
|
|
386
|
+
|
|
387
|
+
ws.on("error", () => {});
|
|
130
388
|
}
|
|
131
389
|
|
|
132
390
|
private handleMessage(data: string): void {
|
|
@@ -266,8 +524,9 @@ export class KichiForwarderService {
|
|
|
266
524
|
|
|
267
525
|
private loadIdentity(): KichiIdentity | null {
|
|
268
526
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
527
|
+
const identityPath = this.getIdentityPath();
|
|
528
|
+
if (!fs.existsSync(identityPath)) return null;
|
|
529
|
+
const data = JSON.parse(fs.readFileSync(identityPath, "utf-8"));
|
|
271
530
|
const avatarId = typeof data.avatarId === "string" && data.avatarId ? data.avatarId : null;
|
|
272
531
|
if (avatarId) {
|
|
273
532
|
return {
|
|
@@ -285,8 +544,10 @@ export class KichiForwarderService {
|
|
|
285
544
|
private saveIdentity(): void {
|
|
286
545
|
if (!this.identity?.avatarId) return;
|
|
287
546
|
try {
|
|
288
|
-
|
|
289
|
-
|
|
547
|
+
const identityDir = this.getIdentityDir();
|
|
548
|
+
const identityPath = this.getIdentityPath();
|
|
549
|
+
if (!fs.existsSync(identityDir)) fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
550
|
+
fs.writeFileSync(identityPath, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
|
|
290
551
|
} catch (e) {
|
|
291
552
|
this.logger.error(`Failed to save identity: ${e}`);
|
|
292
553
|
}
|
|
@@ -311,267 +572,91 @@ export class KichiForwarderService {
|
|
|
311
572
|
return true;
|
|
312
573
|
}
|
|
313
574
|
|
|
314
|
-
|
|
315
|
-
if (!this.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
avatarId: this.identity.avatarId,
|
|
319
|
-
authKey: this.identity.authKey,
|
|
320
|
-
poseType,
|
|
321
|
-
action,
|
|
322
|
-
bubble,
|
|
323
|
-
log,
|
|
324
|
-
};
|
|
325
|
-
this.ws.send(JSON.stringify(payload));
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
sendAvatarCommand(command: AvatarCommand, bubble?: string, log?: string): boolean {
|
|
329
|
-
const identity = this.requireIdentity();
|
|
330
|
-
if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
331
|
-
|
|
332
|
-
const payload: AvatarCommandPayload = {
|
|
333
|
-
type: "avatar_command",
|
|
334
|
-
avatarId: identity.avatarId,
|
|
335
|
-
authKey: identity.authKey,
|
|
336
|
-
command,
|
|
337
|
-
...(typeof bubble === "string" && bubble.trim() ? { bubble: bubble.trim() } : {}),
|
|
338
|
-
...(typeof log === "string" && log.trim() ? { log: log.trim() } : {}),
|
|
339
|
-
};
|
|
340
|
-
this.ws.send(JSON.stringify(payload));
|
|
341
|
-
return true;
|
|
342
|
-
}
|
|
575
|
+
private getWebsocketState(): KichiConnectionStatus["websocketState"] {
|
|
576
|
+
if (!this.ws) {
|
|
577
|
+
return "idle";
|
|
578
|
+
}
|
|
343
579
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
580
|
+
if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
581
|
+
return "connecting";
|
|
582
|
+
}
|
|
583
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
584
|
+
return "open";
|
|
585
|
+
}
|
|
586
|
+
if (this.ws.readyState === WebSocket.CLOSING) {
|
|
587
|
+
return "closing";
|
|
588
|
+
}
|
|
589
|
+
return "closed";
|
|
353
590
|
}
|
|
354
591
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
358
|
-
const payload: WorkspaceScreenPayload = {
|
|
359
|
-
type: "workspace_screen",
|
|
360
|
-
avatarId: identity.avatarId,
|
|
361
|
-
authKey: identity.authKey,
|
|
362
|
-
text,
|
|
363
|
-
ansi,
|
|
364
|
-
};
|
|
365
|
-
this.ws.send(JSON.stringify(payload));
|
|
366
|
-
return true;
|
|
592
|
+
private getIdentityDir(): string {
|
|
593
|
+
return path.join(HOSTS_DIR, encodeURIComponent(this.host));
|
|
367
594
|
}
|
|
368
595
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const basePayload = {
|
|
374
|
-
type: "clock" as const,
|
|
375
|
-
avatarId: this.identity.avatarId,
|
|
376
|
-
authKey: this.identity.authKey,
|
|
377
|
-
...(requestId ? { requestId } : {}),
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
const payload: ClockPayload =
|
|
381
|
-
action === "set"
|
|
382
|
-
? {
|
|
383
|
-
...basePayload,
|
|
384
|
-
action,
|
|
385
|
-
clock,
|
|
386
|
-
}
|
|
387
|
-
: {
|
|
388
|
-
...basePayload,
|
|
389
|
-
action,
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
this.ws.send(JSON.stringify(payload));
|
|
393
|
-
return true;
|
|
596
|
+
private getWsUrl(): string {
|
|
597
|
+
const protocol = this.isPlainIpHost(this.host) || this.host === "localhost" ? "ws" : "wss";
|
|
598
|
+
return `${protocol}://${this.host}:48870/ws/openclaw`;
|
|
394
599
|
}
|
|
395
600
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const payload: QueryStatusPayload = {
|
|
403
|
-
type: "query_status",
|
|
404
|
-
requestId: requestId?.trim() || randomUUID(),
|
|
405
|
-
avatarId: identity.avatarId,
|
|
406
|
-
authKey: identity.authKey,
|
|
407
|
-
};
|
|
408
|
-
return this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
|
|
601
|
+
private isPlainIpHost(host: string): boolean {
|
|
602
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
603
|
+
|| /^\[[0-9a-f:]+\]$/i.test(host)
|
|
604
|
+
|| /^[0-9a-f:]+$/i.test(host);
|
|
409
605
|
}
|
|
410
606
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
607
|
+
private loadCurrentHost(): string {
|
|
608
|
+
try {
|
|
609
|
+
if (!fs.existsSync(STATE_PATH)) {
|
|
610
|
+
this.persistCurrentHost(this.config.defaultHost);
|
|
611
|
+
return this.config.defaultHost;
|
|
612
|
+
}
|
|
613
|
+
const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as { currentHost?: unknown };
|
|
614
|
+
if (typeof data.currentHost === "string" && data.currentHost.trim()) {
|
|
615
|
+
return data.currentHost;
|
|
616
|
+
}
|
|
617
|
+
throw new Error(`Invalid currentHost value in ${STATE_PATH}`);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
throw new Error(`Failed to load current host: ${error}`);
|
|
423
620
|
}
|
|
424
|
-
|
|
425
|
-
const payload: CreateNotesBoardNotePayload = {
|
|
426
|
-
type: "create_notes_board_note",
|
|
427
|
-
avatarId: identity.avatarId,
|
|
428
|
-
authKey: identity.authKey,
|
|
429
|
-
propId,
|
|
430
|
-
data,
|
|
431
|
-
};
|
|
432
|
-
this.ws.send(JSON.stringify(payload));
|
|
433
621
|
}
|
|
434
622
|
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (!albumTitle.trim()) {
|
|
441
|
-
throw new Error("albumTitle is required");
|
|
442
|
-
}
|
|
443
|
-
if (!Array.isArray(musicTitles) || musicTitles.length === 0) {
|
|
444
|
-
throw new Error("musicTitles must contain at least one track title");
|
|
445
|
-
}
|
|
446
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
447
|
-
throw new Error("Kichi websocket is not connected");
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const normalizedRequestId = requestId?.trim() || randomUUID();
|
|
451
|
-
const payload: CreateMusicAlbumPayload = {
|
|
452
|
-
type: "create_music_album",
|
|
453
|
-
requestId: normalizedRequestId,
|
|
454
|
-
avatarId: identity.avatarId,
|
|
455
|
-
authKey: identity.authKey,
|
|
456
|
-
albumTitle: albumTitle.trim(),
|
|
457
|
-
musicTitles,
|
|
623
|
+
private persistCurrentHost(host: string): void {
|
|
624
|
+
const previousState = this.readStateFile();
|
|
625
|
+
const nextState: KichiState = {
|
|
626
|
+
currentHost: host,
|
|
627
|
+
llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
|
|
458
628
|
};
|
|
459
|
-
|
|
460
|
-
|
|
629
|
+
fs.mkdirSync(KICHI_WORLD_DIR, { recursive: true, mode: 0o700 });
|
|
630
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(nextState, null, 2), { mode: 0o600 });
|
|
461
631
|
}
|
|
462
632
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
requestRejoin(): { accepted: boolean; mode: "sent" | "waiting_open" | "reconnecting" | "unavailable"; message: string } {
|
|
468
|
-
if (!this.identity?.avatarId || !this.identity?.authKey) {
|
|
469
|
-
return {
|
|
470
|
-
accepted: false,
|
|
471
|
-
mode: "unavailable",
|
|
472
|
-
message: "Missing authKey. Run kichi_join first.",
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
477
|
-
const sent = this.sendRejoinPayload();
|
|
478
|
-
return {
|
|
479
|
-
accepted: sent,
|
|
480
|
-
mode: sent ? "sent" : "unavailable",
|
|
481
|
-
message: sent ? "Rejoin payload sent." : "Unable to send rejoin payload.",
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
486
|
-
return {
|
|
487
|
-
accepted: true,
|
|
488
|
-
mode: "waiting_open",
|
|
489
|
-
message: "WebSocket is connecting. Rejoin will be sent automatically on open.",
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (this.stopped) {
|
|
494
|
-
return {
|
|
495
|
-
accepted: false,
|
|
496
|
-
mode: "unavailable",
|
|
497
|
-
message: "Service is not running.",
|
|
498
|
-
};
|
|
633
|
+
private readStateFile(): Partial<KichiState> | null {
|
|
634
|
+
if (!fs.existsSync(STATE_PATH)) {
|
|
635
|
+
return null;
|
|
499
636
|
}
|
|
500
|
-
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
this.reconnectTimeout = null;
|
|
637
|
+
const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as unknown;
|
|
638
|
+
if (!data || typeof data !== "object") {
|
|
639
|
+
throw new Error(`Invalid state payload in ${STATE_PATH}`);
|
|
504
640
|
}
|
|
505
|
-
|
|
506
|
-
this.connect();
|
|
507
|
-
return {
|
|
508
|
-
accepted: true,
|
|
509
|
-
mode: "reconnecting",
|
|
510
|
-
message: "Reconnect started. Rejoin will be sent automatically on open.",
|
|
511
|
-
};
|
|
641
|
+
return data as Partial<KichiState>;
|
|
512
642
|
}
|
|
513
643
|
|
|
514
|
-
|
|
515
|
-
return
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
websocketState: this.getWebsocketState(),
|
|
519
|
-
hasIdentity: !!this.identity?.avatarId,
|
|
520
|
-
avatarId: this.identity?.avatarId,
|
|
521
|
-
hasAuthKey: !!this.identity?.authKey,
|
|
522
|
-
pendingRequestCount: this.pendingRequests.size,
|
|
523
|
-
reconnectScheduled: !!this.reconnectTimeout,
|
|
524
|
-
};
|
|
644
|
+
private clearReconnectTimeout(): void {
|
|
645
|
+
if (!this.reconnectTimeout) return;
|
|
646
|
+
clearTimeout(this.reconnectTimeout);
|
|
647
|
+
this.reconnectTimeout = null;
|
|
525
648
|
}
|
|
526
649
|
|
|
527
|
-
private
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
533
|
-
return "connecting";
|
|
534
|
-
}
|
|
535
|
-
if (this.ws.readyState === WebSocket.OPEN) {
|
|
536
|
-
return "open";
|
|
537
|
-
}
|
|
538
|
-
if (this.ws.readyState === WebSocket.CLOSING) {
|
|
539
|
-
return "closing";
|
|
540
|
-
}
|
|
541
|
-
return "closed";
|
|
650
|
+
private closeSocket(): void {
|
|
651
|
+
const socket = this.ws;
|
|
652
|
+
this.ws = null;
|
|
653
|
+
socket?.removeAllListeners();
|
|
654
|
+
socket?.close();
|
|
542
655
|
}
|
|
543
656
|
|
|
544
|
-
|
|
545
|
-
if (!this.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
return new Promise((resolve) => {
|
|
550
|
-
const handler = (data: WebSocket.Data) => {
|
|
551
|
-
try {
|
|
552
|
-
const msg = JSON.parse(data.toString());
|
|
553
|
-
if (msg.type === "leave_ack") {
|
|
554
|
-
this.ws?.off("message", handler);
|
|
555
|
-
const leaveAck = msg as LeaveAckPayload;
|
|
556
|
-
if (leaveAck.success === false) {
|
|
557
|
-
resolve(this.buildAckFailure(leaveAck, "Leave failed"));
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
this.clearAuthKey();
|
|
561
|
-
resolve({ success: true });
|
|
562
|
-
}
|
|
563
|
-
} catch (e) {
|
|
564
|
-
this.logger.warn(`Failed to parse leave response: ${e}`);
|
|
565
|
-
}
|
|
566
|
-
};
|
|
567
|
-
this.ws!.on("message", handler);
|
|
568
|
-
this.ws!.send(
|
|
569
|
-
JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
|
|
570
|
-
);
|
|
571
|
-
setTimeout(() => {
|
|
572
|
-
this.ws?.off("message", handler);
|
|
573
|
-
resolve({ success: false, error: "Timed out waiting for leave_ack" });
|
|
574
|
-
}, 10000);
|
|
575
|
-
});
|
|
657
|
+
private failPendingJoin(reason: string): void {
|
|
658
|
+
if (!this.joinResolve) return;
|
|
659
|
+
this.joinResolve({ success: false, error: reason });
|
|
660
|
+
this.joinResolve = null;
|
|
576
661
|
}
|
|
577
662
|
}
|