@yahaha-studio/kichi-forwarder 0.1.0-beta.9 → 0.1.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/index.ts +265 -113
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +6 -6
- package/skills/kichi-forwarder/references/install.md +22 -18
- package/src/runtime-manager.ts +240 -0
- package/src/service.ts +134 -44
- package/src/types.ts +3 -0
package/src/service.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import * as fs from "fs";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import * as path from "path";
|
|
5
4
|
import { randomUUID } from "node:crypto";
|
|
6
5
|
import type { Logger } from "openclaw/plugin-sdk";
|
|
@@ -27,9 +26,6 @@ import type {
|
|
|
27
26
|
StatusPayload,
|
|
28
27
|
} from "./types.js";
|
|
29
28
|
|
|
30
|
-
const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
31
|
-
const HOSTS_DIR = path.join(KICHI_WORLD_DIR, "hosts");
|
|
32
|
-
const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
|
|
33
29
|
const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
34
30
|
const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
35
31
|
|
|
@@ -53,10 +49,18 @@ export type LeaveResult =
|
|
|
53
49
|
}
|
|
54
50
|
| AckFailureResult;
|
|
55
51
|
|
|
52
|
+
type KichiForwarderServiceOptions = {
|
|
53
|
+
agentId: string;
|
|
54
|
+
runtimeDir: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type ConnectReason = "startup" | "switch_host" | "reconnect";
|
|
58
|
+
|
|
56
59
|
export class KichiForwarderService {
|
|
57
60
|
private ws: WebSocket | null = null;
|
|
58
61
|
private stopped = false;
|
|
59
62
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
63
|
+
private joinTimeout: NodeJS.Timeout | null = null;
|
|
60
64
|
private identity: KichiIdentity | null = null;
|
|
61
65
|
private host: string | null = null;
|
|
62
66
|
private joinResolve: ((result: JoinResult) => void) | null = null;
|
|
@@ -70,20 +74,23 @@ export class KichiForwarderService {
|
|
|
70
74
|
}
|
|
71
75
|
>();
|
|
72
76
|
|
|
73
|
-
constructor(
|
|
77
|
+
constructor(
|
|
78
|
+
private logger: Logger,
|
|
79
|
+
private options: KichiForwarderServiceOptions,
|
|
80
|
+
) {}
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
start(): void {
|
|
76
83
|
this.host = this.loadCurrentHost();
|
|
77
84
|
this.identity = this.host ? this.loadIdentity() : null;
|
|
78
85
|
this.stopped = false;
|
|
79
86
|
if (this.host) {
|
|
80
|
-
this.connect();
|
|
87
|
+
this.connect("startup");
|
|
81
88
|
return;
|
|
82
89
|
}
|
|
83
|
-
this.
|
|
90
|
+
this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
stop(): void {
|
|
87
94
|
this.stopped = true;
|
|
88
95
|
this.clearReconnectTimeout();
|
|
89
96
|
this.rejectPendingRequests("Kichi websocket stopped");
|
|
@@ -100,7 +107,7 @@ export class KichiForwarderService {
|
|
|
100
107
|
this.failPendingJoin(`Kichi websocket switched to ${host}`);
|
|
101
108
|
this.closeSocket();
|
|
102
109
|
if (!this.stopped) {
|
|
103
|
-
this.connect();
|
|
110
|
+
this.connect("switch_host");
|
|
104
111
|
}
|
|
105
112
|
return this.getConnectionStatus();
|
|
106
113
|
}
|
|
@@ -114,7 +121,14 @@ export class KichiForwarderService {
|
|
|
114
121
|
if (!this.host) {
|
|
115
122
|
return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
|
|
116
123
|
}
|
|
124
|
+
if (this.ws?.readyState !== WebSocket.OPEN && this.ws?.readyState !== WebSocket.CONNECTING) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: "Kichi websocket is not connected. Restart the gateway to reconnect before joining.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
117
130
|
return new Promise((resolve) => {
|
|
131
|
+
this.failPendingJoin("Kichi join superseded by a new join request");
|
|
118
132
|
this.identity = { avatarId };
|
|
119
133
|
this.saveIdentity();
|
|
120
134
|
this.joinResolve = resolve;
|
|
@@ -125,9 +139,10 @@ export class KichiForwarderService {
|
|
|
125
139
|
} else {
|
|
126
140
|
this.ws?.once("open", sendJoin);
|
|
127
141
|
}
|
|
128
|
-
setTimeout(() => {
|
|
142
|
+
this.joinTimeout = setTimeout(() => {
|
|
129
143
|
if (this.joinResolve) {
|
|
130
144
|
this.joinResolve = null;
|
|
145
|
+
this.clearJoinTimeout();
|
|
131
146
|
resolve({ success: false, error: "Timed out waiting for join_ack" });
|
|
132
147
|
}
|
|
133
148
|
}, 10000);
|
|
@@ -270,10 +285,26 @@ export class KichiForwarderService {
|
|
|
270
285
|
|
|
271
286
|
hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
|
|
272
287
|
|
|
288
|
+
isLlmRuntimeEnabled(): boolean {
|
|
289
|
+
return this.readStateFile()?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED;
|
|
290
|
+
}
|
|
291
|
+
|
|
273
292
|
getCurrentHost(): string {
|
|
274
293
|
return this.host ?? "";
|
|
275
294
|
}
|
|
276
295
|
|
|
296
|
+
getAgentId(): string {
|
|
297
|
+
return this.options.agentId;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getRuntimeDir(): string {
|
|
301
|
+
return this.options.runtimeDir;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getStatePath(): string {
|
|
305
|
+
return path.join(this.options.runtimeDir, "state.json");
|
|
306
|
+
}
|
|
307
|
+
|
|
277
308
|
getIdentityPath(): string {
|
|
278
309
|
if (!this.host) {
|
|
279
310
|
return "";
|
|
@@ -324,18 +355,27 @@ export class KichiForwarderService {
|
|
|
324
355
|
};
|
|
325
356
|
}
|
|
326
357
|
|
|
327
|
-
this.
|
|
328
|
-
|
|
358
|
+
if (this.reconnectTimeout) {
|
|
359
|
+
return {
|
|
360
|
+
accepted: true,
|
|
361
|
+
mode: "reconnecting",
|
|
362
|
+
message: "WebSocket reconnect is already scheduled. Rejoin will be sent automatically on open.",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
329
366
|
return {
|
|
330
|
-
accepted:
|
|
331
|
-
mode: "
|
|
332
|
-
message: "
|
|
367
|
+
accepted: false,
|
|
368
|
+
mode: "unavailable",
|
|
369
|
+
message: "WebSocket is not connected. Restart the gateway or wait for the scheduled reconnect.",
|
|
333
370
|
};
|
|
334
371
|
}
|
|
335
372
|
|
|
336
373
|
getConnectionStatus(): KichiConnectionStatus {
|
|
337
374
|
const host = this.host ?? undefined;
|
|
338
375
|
return {
|
|
376
|
+
agentId: this.options.agentId,
|
|
377
|
+
runtimeDir: this.getRuntimeDir(),
|
|
378
|
+
statePath: this.getStatePath(),
|
|
339
379
|
...(host ? {
|
|
340
380
|
host,
|
|
341
381
|
wsUrl: this.getWsUrl(),
|
|
@@ -372,7 +412,7 @@ export class KichiForwarderService {
|
|
|
372
412
|
resolve({ success: true });
|
|
373
413
|
}
|
|
374
414
|
} catch (e) {
|
|
375
|
-
this.
|
|
415
|
+
this.log("warn", `failed to parse leave response: ${e}`);
|
|
376
416
|
}
|
|
377
417
|
};
|
|
378
418
|
this.ws!.on("message", handler);
|
|
@@ -386,16 +426,22 @@ export class KichiForwarderService {
|
|
|
386
426
|
});
|
|
387
427
|
}
|
|
388
428
|
|
|
389
|
-
private connect(): void {
|
|
429
|
+
private connect(reason: ConnectReason): void {
|
|
390
430
|
if (this.stopped || !this.host) return;
|
|
431
|
+
if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
|
|
432
|
+
this.log("debug", `skipped websocket connect (${reason}) because socket is already ${this.getWebsocketState()}`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
391
435
|
|
|
436
|
+
this.clearReconnectTimeout();
|
|
392
437
|
const wsUrl = this.getWsUrl();
|
|
393
438
|
const ws = new WebSocket(wsUrl);
|
|
394
439
|
this.ws = ws;
|
|
440
|
+
this.log("debug", `opening websocket (${reason}) to ${wsUrl}`);
|
|
395
441
|
|
|
396
442
|
ws.on("open", () => {
|
|
397
443
|
if (this.ws !== ws) return;
|
|
398
|
-
this.
|
|
444
|
+
this.log("info", `connected to ${wsUrl} (${this.host})`);
|
|
399
445
|
this.sendRejoinPayload();
|
|
400
446
|
});
|
|
401
447
|
|
|
@@ -408,19 +454,20 @@ export class KichiForwarderService {
|
|
|
408
454
|
if (this.ws !== ws) return;
|
|
409
455
|
this.ws = null;
|
|
410
456
|
this.rejectPendingRequests("Kichi websocket closed");
|
|
457
|
+
this.failPendingJoin("Kichi websocket closed");
|
|
411
458
|
if (!this.stopped) {
|
|
412
|
-
this.
|
|
413
|
-
this.reconnectTimeout = null;
|
|
414
|
-
this.connect();
|
|
415
|
-
}, 2000);
|
|
459
|
+
this.scheduleReconnect();
|
|
416
460
|
}
|
|
417
461
|
});
|
|
418
462
|
|
|
419
|
-
ws.on("error", () => {
|
|
463
|
+
ws.on("error", (error) => {
|
|
464
|
+
if (this.ws !== ws) return;
|
|
465
|
+
this.log("warn", `websocket error: ${error instanceof Error ? error.message : String(error)}`);
|
|
466
|
+
});
|
|
420
467
|
}
|
|
421
468
|
|
|
422
469
|
private handleMessage(data: string): void {
|
|
423
|
-
this.
|
|
470
|
+
this.log("debug", `ws recv ${data}`);
|
|
424
471
|
try {
|
|
425
472
|
const msg = JSON.parse(data);
|
|
426
473
|
this.tryResolvePendingRequest(msg);
|
|
@@ -428,33 +475,35 @@ export class KichiForwarderService {
|
|
|
428
475
|
const joinAck = msg as JoinAckPayload;
|
|
429
476
|
if (joinAck.success === false || !joinAck.authKey) {
|
|
430
477
|
const failure = this.buildAckFailure(joinAck, "Join failed");
|
|
431
|
-
this.
|
|
478
|
+
this.log("warn", `join failed: ${failure.error}`);
|
|
432
479
|
this.joinResolve?.(failure);
|
|
433
480
|
this.joinResolve = null;
|
|
481
|
+
this.clearJoinTimeout();
|
|
434
482
|
return;
|
|
435
483
|
}
|
|
436
484
|
|
|
437
485
|
if (this.identity) {
|
|
438
486
|
this.identity.authKey = joinAck.authKey;
|
|
439
487
|
this.saveIdentity();
|
|
440
|
-
this.
|
|
488
|
+
this.log("info", `joined as ${this.identity.avatarId}`);
|
|
441
489
|
}
|
|
442
490
|
this.joinResolve?.({ success: true, authKey: joinAck.authKey });
|
|
443
491
|
this.joinResolve = null;
|
|
492
|
+
this.clearJoinTimeout();
|
|
444
493
|
} else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
|
|
445
|
-
this.
|
|
494
|
+
this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
|
|
446
495
|
this.clearAuthKey();
|
|
447
496
|
} else if (msg.type === "leave_ack") {
|
|
448
497
|
const leaveAck = msg as LeaveAckPayload;
|
|
449
498
|
if (leaveAck.success === false) {
|
|
450
499
|
const failure = this.buildAckFailure(leaveAck, "Leave failed");
|
|
451
|
-
this.
|
|
500
|
+
this.log("warn", `leave failed: ${failure.error}`);
|
|
452
501
|
} else {
|
|
453
|
-
this.
|
|
502
|
+
this.log("info", "left Kichi world");
|
|
454
503
|
}
|
|
455
504
|
}
|
|
456
505
|
} catch (e) {
|
|
457
|
-
this.
|
|
506
|
+
this.log("warn", `failed to parse message: ${e}`);
|
|
458
507
|
}
|
|
459
508
|
}
|
|
460
509
|
|
|
@@ -571,7 +620,7 @@ export class KichiForwarderService {
|
|
|
571
620
|
}
|
|
572
621
|
return null;
|
|
573
622
|
} catch (e) {
|
|
574
|
-
this.
|
|
623
|
+
this.log("warn", `failed to load identity: ${e}`);
|
|
575
624
|
return null;
|
|
576
625
|
}
|
|
577
626
|
}
|
|
@@ -584,7 +633,7 @@ export class KichiForwarderService {
|
|
|
584
633
|
if (!fs.existsSync(identityDir)) fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
585
634
|
fs.writeFileSync(identityPath, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
|
|
586
635
|
} catch (e) {
|
|
587
|
-
this.
|
|
636
|
+
this.log("error", `failed to save identity: ${e}`);
|
|
588
637
|
}
|
|
589
638
|
}
|
|
590
639
|
|
|
@@ -592,7 +641,7 @@ export class KichiForwarderService {
|
|
|
592
641
|
if (!this.identity) return;
|
|
593
642
|
this.identity.authKey = undefined;
|
|
594
643
|
this.saveIdentity();
|
|
595
|
-
this.
|
|
644
|
+
this.log("info", "authKey cleared");
|
|
596
645
|
}
|
|
597
646
|
|
|
598
647
|
private sendRejoinPayload(): boolean {
|
|
@@ -603,7 +652,7 @@ export class KichiForwarderService {
|
|
|
603
652
|
this.ws.send(
|
|
604
653
|
JSON.stringify({ type: "rejoin", avatarId: this.identity.avatarId, authKey: this.identity.authKey }),
|
|
605
654
|
);
|
|
606
|
-
this.
|
|
655
|
+
this.log("debug", `sent rejoin for ${this.identity.avatarId}`);
|
|
607
656
|
return true;
|
|
608
657
|
}
|
|
609
658
|
|
|
@@ -628,7 +677,7 @@ export class KichiForwarderService {
|
|
|
628
677
|
if (!this.host) {
|
|
629
678
|
throw new Error("No Kichi host configured");
|
|
630
679
|
}
|
|
631
|
-
return path.join(
|
|
680
|
+
return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
|
|
632
681
|
}
|
|
633
682
|
|
|
634
683
|
private getWsUrl(): string {
|
|
@@ -647,14 +696,15 @@ export class KichiForwarderService {
|
|
|
647
696
|
|
|
648
697
|
private loadCurrentHost(): string | null {
|
|
649
698
|
try {
|
|
650
|
-
|
|
699
|
+
const statePath = this.getStatePath();
|
|
700
|
+
if (!fs.existsSync(statePath)) {
|
|
651
701
|
return null;
|
|
652
702
|
}
|
|
653
|
-
const data = JSON.parse(fs.readFileSync(
|
|
703
|
+
const data = JSON.parse(fs.readFileSync(statePath, "utf-8")) as { currentHost?: unknown };
|
|
654
704
|
if (typeof data.currentHost === "string" && data.currentHost.trim()) {
|
|
655
705
|
return data.currentHost;
|
|
656
706
|
}
|
|
657
|
-
throw new Error(`Invalid currentHost value in ${
|
|
707
|
+
throw new Error(`Invalid currentHost value in ${statePath}`);
|
|
658
708
|
} catch (error) {
|
|
659
709
|
throw new Error(`Failed to load current host: ${error}`);
|
|
660
710
|
}
|
|
@@ -666,17 +716,18 @@ export class KichiForwarderService {
|
|
|
666
716
|
currentHost: host,
|
|
667
717
|
llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
|
|
668
718
|
};
|
|
669
|
-
fs.mkdirSync(
|
|
670
|
-
fs.writeFileSync(
|
|
719
|
+
fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
|
|
720
|
+
fs.writeFileSync(this.getStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
|
|
671
721
|
}
|
|
672
722
|
|
|
673
723
|
private readStateFile(): Partial<KichiState> | null {
|
|
674
|
-
|
|
724
|
+
const statePath = this.getStatePath();
|
|
725
|
+
if (!fs.existsSync(statePath)) {
|
|
675
726
|
return null;
|
|
676
727
|
}
|
|
677
|
-
const data = JSON.parse(fs.readFileSync(
|
|
728
|
+
const data = JSON.parse(fs.readFileSync(statePath, "utf-8")) as unknown;
|
|
678
729
|
if (!data || typeof data !== "object") {
|
|
679
|
-
throw new Error(`Invalid state payload in ${
|
|
730
|
+
throw new Error(`Invalid state payload in ${statePath}`);
|
|
680
731
|
}
|
|
681
732
|
return data as Partial<KichiState>;
|
|
682
733
|
}
|
|
@@ -687,6 +738,22 @@ export class KichiForwarderService {
|
|
|
687
738
|
this.reconnectTimeout = null;
|
|
688
739
|
}
|
|
689
740
|
|
|
741
|
+
private clearJoinTimeout(): void {
|
|
742
|
+
if (!this.joinTimeout) return;
|
|
743
|
+
clearTimeout(this.joinTimeout);
|
|
744
|
+
this.joinTimeout = null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private scheduleReconnect(): void {
|
|
748
|
+
if (this.reconnectTimeout || this.stopped) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
752
|
+
this.reconnectTimeout = null;
|
|
753
|
+
this.connect("reconnect");
|
|
754
|
+
}, 2000);
|
|
755
|
+
}
|
|
756
|
+
|
|
690
757
|
private closeSocket(): void {
|
|
691
758
|
const socket = this.ws;
|
|
692
759
|
this.ws = null;
|
|
@@ -698,5 +765,28 @@ export class KichiForwarderService {
|
|
|
698
765
|
if (!this.joinResolve) return;
|
|
699
766
|
this.joinResolve({ success: false, error: reason });
|
|
700
767
|
this.joinResolve = null;
|
|
768
|
+
this.clearJoinTimeout();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private logPrefix(): string {
|
|
772
|
+
return `[kichi:${this.options.agentId}]`;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private log(level: "debug" | "info" | "warn" | "error", message: string): void {
|
|
776
|
+
const formatted = `${this.logPrefix()} ${message}`;
|
|
777
|
+
switch (level) {
|
|
778
|
+
case "debug":
|
|
779
|
+
this.logger.debug(formatted);
|
|
780
|
+
return;
|
|
781
|
+
case "info":
|
|
782
|
+
this.logger.info(formatted);
|
|
783
|
+
return;
|
|
784
|
+
case "warn":
|
|
785
|
+
this.logger.warn(formatted);
|
|
786
|
+
return;
|
|
787
|
+
case "error":
|
|
788
|
+
this.logger.error(formatted);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
701
791
|
}
|
|
702
792
|
}
|