@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/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(private logger: Logger) {}
77
+ constructor(
78
+ private logger: Logger,
79
+ private options: KichiForwarderServiceOptions,
80
+ ) {}
74
81
 
75
- async start(): Promise<void> {
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.logger.info("Kichi host is not configured yet; waiting for kichi_switch_host");
90
+ this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
84
91
  }
85
92
 
86
- async stop(): Promise<void> {
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.clearReconnectTimeout();
328
- this.connect();
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: true,
331
- mode: "reconnecting",
332
- message: "Reconnect started. Rejoin will be sent automatically on open.",
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.logger.warn(`Failed to parse leave response: ${e}`);
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.logger.info(`Connected to ${wsUrl} (${this.host})`);
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.reconnectTimeout = setTimeout(() => {
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.logger.debug(`[kichi ws recv] ${data}`);
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.logger.warn(`Join failed: ${failure.error}`);
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.logger.info(`Joined as ${this.identity.avatarId}`);
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.logger.warn(`Auth failed: ${msg.reason || "unknown"}`);
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.logger.warn(`Leave failed: ${failure.error}`);
500
+ this.log("warn", `leave failed: ${failure.error}`);
452
501
  } else {
453
- this.logger.info("Left Kichi world");
502
+ this.log("info", "left Kichi world");
454
503
  }
455
504
  }
456
505
  } catch (e) {
457
- this.logger.warn(`Failed to parse message: ${e}`);
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.logger.warn(`Failed to load identity: ${e}`);
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.logger.error(`Failed to save identity: ${e}`);
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.logger.info("AuthKey cleared");
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.logger.debug(`Sent rejoin for ${this.identity.avatarId}`);
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(HOSTS_DIR, encodeURIComponent(this.host));
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
- if (!fs.existsSync(STATE_PATH)) {
699
+ const statePath = this.getStatePath();
700
+ if (!fs.existsSync(statePath)) {
651
701
  return null;
652
702
  }
653
- const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as { currentHost?: unknown };
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 ${STATE_PATH}`);
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(KICHI_WORLD_DIR, { recursive: true, mode: 0o700 });
670
- fs.writeFileSync(STATE_PATH, JSON.stringify(nextState, null, 2), { mode: 0o600 });
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
- if (!fs.existsSync(STATE_PATH)) {
724
+ const statePath = this.getStatePath();
725
+ if (!fs.existsSync(statePath)) {
675
726
  return null;
676
727
  }
677
- const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as unknown;
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 ${STATE_PATH}`);
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
  }
package/src/types.ts CHANGED
@@ -47,6 +47,9 @@ export type KichiIdentity = {
47
47
  };
48
48
 
49
49
  export type KichiConnectionStatus = {
50
+ agentId?: string;
51
+ runtimeDir?: string;
52
+ statePath?: string;
50
53
  host?: string;
51
54
  wsUrl?: string;
52
55
  identityPath?: string;