@yahaha-studio/kichi-forwarder 0.1.2-beta.1 → 0.1.2-beta.4

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.
@@ -0,0 +1,701 @@
1
+ import WebSocket from "ws";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { randomUUID } from "node:crypto";
5
+ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
6
+ const DEFAULT_LLM_RUNTIME_ENABLED = true;
7
+ export class KichiForwarderService {
8
+ logger;
9
+ options;
10
+ ws = null;
11
+ stopped = false;
12
+ reconnectTimeout = null;
13
+ joinTimeout = null;
14
+ identity = null;
15
+ host = null;
16
+ environment = null;
17
+ joinResolve = null;
18
+ pendingRequests = new Map();
19
+ onBotMessageReceived = null;
20
+ constructor(logger, options) {
21
+ this.logger = logger;
22
+ this.options = options;
23
+ }
24
+ start() {
25
+ const state = this.readStateFile();
26
+ this.environment = state?.currentEnvironment ?? null;
27
+ if (this.environment) {
28
+ this.host = this.options.resolveEnvironmentHost(this.environment);
29
+ }
30
+ else {
31
+ this.host = null;
32
+ }
33
+ this.identity = this.host ? this.loadIdentity() : null;
34
+ this.stopped = false;
35
+ if (this.host) {
36
+ this.connect("startup");
37
+ return;
38
+ }
39
+ this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
40
+ }
41
+ stop() {
42
+ this.stopped = true;
43
+ this.clearReconnectTimeout();
44
+ this.rejectPendingRequests("Kichi websocket stopped");
45
+ this.failPendingJoin("Kichi websocket stopped");
46
+ this.closeSocket();
47
+ }
48
+ async switchHost(host, environment) {
49
+ this.persistCurrentHost(host, environment);
50
+ this.host = host;
51
+ this.environment = environment ?? null;
52
+ this.identity = this.loadIdentity();
53
+ this.clearReconnectTimeout();
54
+ this.rejectPendingRequests(`Kichi websocket switched to ${host}`);
55
+ this.failPendingJoin(`Kichi websocket switched to ${host}`);
56
+ this.closeSocket();
57
+ if (!this.stopped) {
58
+ this.connect("switch_host");
59
+ }
60
+ return this.getConnectionStatus();
61
+ }
62
+ async join(avatarId, botName, bio, tags) {
63
+ if (!this.host) {
64
+ return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
65
+ }
66
+ if (this.ws?.readyState !== WebSocket.OPEN && this.ws?.readyState !== WebSocket.CONNECTING) {
67
+ return {
68
+ success: false,
69
+ error: "Kichi websocket is not connected. Restart the gateway to reconnect before joining.",
70
+ };
71
+ }
72
+ return new Promise((resolve) => {
73
+ this.failPendingJoin("Kichi join superseded by a new join request");
74
+ this.identity = { avatarId };
75
+ this.saveIdentity();
76
+ this.joinResolve = resolve;
77
+ const payload = { type: "join", avatarId, botName, bio, tags };
78
+ const sendJoin = () => this.ws?.send(JSON.stringify(payload));
79
+ if (this.ws?.readyState === WebSocket.OPEN) {
80
+ sendJoin();
81
+ }
82
+ else {
83
+ this.ws?.once("open", sendJoin);
84
+ }
85
+ this.joinTimeout = setTimeout(() => {
86
+ if (this.joinResolve) {
87
+ this.joinResolve = null;
88
+ this.clearJoinTimeout();
89
+ resolve({ success: false, error: "Timed out waiting for join_ack" });
90
+ }
91
+ }, 10000);
92
+ });
93
+ }
94
+ sendStatus(poseType, action, bubble, log, playback) {
95
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN)
96
+ return;
97
+ const payload = {
98
+ type: "status",
99
+ avatarId: this.identity.avatarId,
100
+ authKey: this.identity.authKey,
101
+ poseType,
102
+ action,
103
+ bubble,
104
+ log,
105
+ playback,
106
+ };
107
+ this.ws.send(JSON.stringify(payload));
108
+ }
109
+ async sendStatusVerified(poseType, action, bubble, log, playback) {
110
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
111
+ throw new Error("Kichi websocket is not connected");
112
+ }
113
+ const payload = {
114
+ type: "status",
115
+ requestId: randomUUID(),
116
+ avatarId: this.identity.avatarId,
117
+ authKey: this.identity.authKey,
118
+ poseType,
119
+ action,
120
+ bubble,
121
+ log,
122
+ playback,
123
+ };
124
+ return this.sendRequest(payload, "status_ack", 5000);
125
+ }
126
+ sendHookNotify(hookType, bubble) {
127
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN)
128
+ return;
129
+ const payload = {
130
+ type: hookType,
131
+ avatarId: this.identity.avatarId,
132
+ authKey: this.identity.authKey,
133
+ bubble,
134
+ };
135
+ this.ws.send(JSON.stringify(payload));
136
+ }
137
+ sendIdlePlan(payload) {
138
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN)
139
+ return false;
140
+ const outboundPayload = {
141
+ type: "kichi_idle_plan",
142
+ avatarId: this.identity.avatarId,
143
+ authKey: this.identity.authKey,
144
+ ...payload,
145
+ };
146
+ this.ws.send(JSON.stringify(outboundPayload));
147
+ return true;
148
+ }
149
+ sendClock(action, clock, requestId) {
150
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN)
151
+ return false;
152
+ if (action === "set" && !clock)
153
+ return false;
154
+ const basePayload = {
155
+ type: "clock",
156
+ avatarId: this.identity.avatarId,
157
+ authKey: this.identity.authKey,
158
+ ...(requestId ? { requestId } : {}),
159
+ };
160
+ const payload = action === "set"
161
+ ? {
162
+ ...basePayload,
163
+ action,
164
+ clock,
165
+ }
166
+ : {
167
+ ...basePayload,
168
+ action,
169
+ };
170
+ this.ws.send(JSON.stringify(payload));
171
+ return true;
172
+ }
173
+ async queryStatus(requestId) {
174
+ const identity = this.requireIdentity();
175
+ if (!identity) {
176
+ throw new Error("Missing Kichi identity");
177
+ }
178
+ const payload = {
179
+ type: "query_status",
180
+ requestId: requestId?.trim() || randomUUID(),
181
+ avatarId: identity.avatarId,
182
+ authKey: identity.authKey,
183
+ };
184
+ return this.sendRequest(payload, "query_status_result");
185
+ }
186
+ createNotesBoardNote(propId, data) {
187
+ const identity = this.requireIdentity();
188
+ if (!identity) {
189
+ throw new Error("Missing Kichi identity");
190
+ }
191
+ if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
192
+ throw new Error(`Note content must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`);
193
+ }
194
+ if (this.ws?.readyState !== WebSocket.OPEN) {
195
+ throw new Error("Kichi websocket is not connected");
196
+ }
197
+ const payload = {
198
+ type: "create_notes_board_note",
199
+ avatarId: identity.avatarId,
200
+ authKey: identity.authKey,
201
+ propId,
202
+ data,
203
+ };
204
+ this.ws.send(JSON.stringify(payload));
205
+ }
206
+ createMusicAlbum(albumTitle, musicTitles, requestId) {
207
+ const identity = this.requireIdentity();
208
+ if (!identity) {
209
+ throw new Error("Missing Kichi identity");
210
+ }
211
+ if (!albumTitle.trim()) {
212
+ throw new Error("albumTitle is required");
213
+ }
214
+ if (!Array.isArray(musicTitles) || musicTitles.length === 0) {
215
+ throw new Error("musicTitles must contain at least one track title");
216
+ }
217
+ if (this.ws?.readyState !== WebSocket.OPEN) {
218
+ throw new Error("Kichi websocket is not connected");
219
+ }
220
+ const normalizedRequestId = requestId?.trim() || randomUUID();
221
+ const payload = {
222
+ type: "create_music_album",
223
+ requestId: normalizedRequestId,
224
+ avatarId: identity.avatarId,
225
+ authKey: identity.authKey,
226
+ albumTitle: albumTitle.trim(),
227
+ musicTitles,
228
+ };
229
+ this.ws.send(JSON.stringify(payload));
230
+ return normalizedRequestId;
231
+ }
232
+ async sendBotMessage(toAvatarId, depth, bubble, options) {
233
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
234
+ throw new Error("Kichi websocket is not connected");
235
+ }
236
+ const payload = {
237
+ type: "bot_message",
238
+ avatarId: this.identity.avatarId,
239
+ authKey: this.identity.authKey,
240
+ toAvatarId,
241
+ depth,
242
+ bubble,
243
+ requestId: randomUUID(),
244
+ ...(options?.poseType ? { poseType: options.poseType } : {}),
245
+ ...(options?.action ? { action: options.action } : {}),
246
+ ...(options?.playback ? { playback: options.playback } : {}),
247
+ ...(options?.log ? { log: options.log } : {}),
248
+ ...(options?.history?.length ? { history: options.history } : {}),
249
+ };
250
+ return this.sendRequest(payload, "bot_message_ack", 5000);
251
+ }
252
+ isConnected() { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
253
+ hasValidIdentity() { return !!this.identity?.avatarId && !!this.identity?.authKey; }
254
+ isLlmRuntimeEnabled() {
255
+ return this.readStateFile()?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED;
256
+ }
257
+ getCurrentHost() {
258
+ return this.host ?? "";
259
+ }
260
+ getAgentId() {
261
+ return this.options.agentId;
262
+ }
263
+ getRuntimeDir() {
264
+ return this.options.runtimeDir;
265
+ }
266
+ getStatePath() {
267
+ return path.join(this.options.runtimeDir, "state.json");
268
+ }
269
+ getIdentityPath() {
270
+ if (!this.host) {
271
+ return "";
272
+ }
273
+ return path.join(this.getIdentityDir(), "identity.json");
274
+ }
275
+ readSavedAvatarId() {
276
+ if (!this.host) {
277
+ return null;
278
+ }
279
+ return this.loadIdentity()?.avatarId ?? null;
280
+ }
281
+ requestRejoin() {
282
+ if (!this.identity?.avatarId || !this.identity?.authKey) {
283
+ return {
284
+ accepted: false,
285
+ mode: "unavailable",
286
+ message: this.host
287
+ ? "Missing authKey. Run kichi_join first."
288
+ : "No Kichi host configured. Run kichi_switch_host first.",
289
+ };
290
+ }
291
+ if (this.ws?.readyState === WebSocket.OPEN) {
292
+ const sent = this.sendRejoinPayload();
293
+ return {
294
+ accepted: sent,
295
+ mode: sent ? "sent" : "unavailable",
296
+ message: sent ? "Rejoin payload sent." : "Unable to send rejoin payload.",
297
+ };
298
+ }
299
+ if (this.ws?.readyState === WebSocket.CONNECTING) {
300
+ return {
301
+ accepted: true,
302
+ mode: "waiting_open",
303
+ message: "WebSocket is connecting. Rejoin will be sent automatically on open.",
304
+ };
305
+ }
306
+ if (this.stopped) {
307
+ return {
308
+ accepted: false,
309
+ mode: "unavailable",
310
+ message: "Service is not running.",
311
+ };
312
+ }
313
+ if (this.reconnectTimeout) {
314
+ return {
315
+ accepted: true,
316
+ mode: "reconnecting",
317
+ message: "WebSocket reconnect is already scheduled. Rejoin will be sent automatically on open.",
318
+ };
319
+ }
320
+ return {
321
+ accepted: false,
322
+ mode: "unavailable",
323
+ message: "WebSocket is not connected. Restart the gateway or wait for the scheduled reconnect.",
324
+ };
325
+ }
326
+ getConnectionStatus() {
327
+ const host = this.host ?? undefined;
328
+ return {
329
+ agentId: this.options.agentId,
330
+ runtimeDir: this.getRuntimeDir(),
331
+ statePath: this.getStatePath(),
332
+ ...(host ? {
333
+ host,
334
+ wsUrl: this.getWsUrl(),
335
+ identityPath: this.getIdentityPath(),
336
+ } : {}),
337
+ ...(this.environment ? { environment: this.environment } : {}),
338
+ hostConfigured: !!host,
339
+ connected: this.isConnected(),
340
+ websocketState: this.getWebsocketState(),
341
+ hasIdentity: !!this.identity?.avatarId,
342
+ avatarId: this.identity?.avatarId,
343
+ hasAuthKey: !!this.identity?.authKey,
344
+ pendingRequestCount: this.pendingRequests.size,
345
+ reconnectScheduled: !!this.reconnectTimeout,
346
+ };
347
+ }
348
+ async leave() {
349
+ if (!this.identity?.avatarId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
350
+ return { success: false, error: "Failed or not connected" };
351
+ }
352
+ return new Promise((resolve) => {
353
+ const handler = (data) => {
354
+ try {
355
+ const msg = JSON.parse(data.toString());
356
+ if (msg.type === "leave_ack") {
357
+ this.ws?.off("message", handler);
358
+ const leaveAck = msg;
359
+ if (leaveAck.success === false) {
360
+ resolve(this.buildAckFailure(leaveAck, "Leave failed"));
361
+ return;
362
+ }
363
+ this.clearAuthKey();
364
+ resolve({ success: true });
365
+ }
366
+ }
367
+ catch (e) {
368
+ this.log("warn", `failed to parse leave response: ${e}`);
369
+ }
370
+ };
371
+ this.ws.on("message", handler);
372
+ this.ws.send(JSON.stringify({ type: "leave", avatarId: this.identity.avatarId, authKey: this.identity.authKey }));
373
+ setTimeout(() => {
374
+ this.ws?.off("message", handler);
375
+ resolve({ success: false, error: "Timed out waiting for leave_ack" });
376
+ }, 10000);
377
+ });
378
+ }
379
+ connect(reason) {
380
+ if (this.stopped || !this.host)
381
+ return;
382
+ if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
383
+ this.log("debug", `skipped websocket connect (${reason}) because socket is already ${this.getWebsocketState()}`);
384
+ return;
385
+ }
386
+ this.clearReconnectTimeout();
387
+ const wsUrl = this.getWsUrl();
388
+ const ws = new WebSocket(wsUrl);
389
+ this.ws = ws;
390
+ this.log("debug", `opening websocket (${reason}) to ${wsUrl}`);
391
+ ws.on("open", () => {
392
+ if (this.ws !== ws)
393
+ return;
394
+ this.log("info", `connected to ${wsUrl} (${this.host})`);
395
+ this.sendRejoinPayload();
396
+ });
397
+ ws.on("message", (data) => {
398
+ if (this.ws !== ws)
399
+ return;
400
+ this.handleMessage(data.toString());
401
+ });
402
+ ws.on("close", () => {
403
+ if (this.ws !== ws)
404
+ return;
405
+ this.ws = null;
406
+ this.rejectPendingRequests("Kichi websocket closed");
407
+ this.failPendingJoin("Kichi websocket closed");
408
+ if (!this.stopped) {
409
+ this.scheduleReconnect();
410
+ }
411
+ });
412
+ ws.on("error", (error) => {
413
+ if (this.ws !== ws)
414
+ return;
415
+ this.log("warn", `websocket error: ${error instanceof Error ? error.message : String(error)}`);
416
+ });
417
+ }
418
+ handleMessage(data) {
419
+ this.log("debug", `ws recv ${data}`);
420
+ try {
421
+ const msg = JSON.parse(data);
422
+ this.tryResolvePendingRequest(msg);
423
+ if (msg.type === "join_ack") {
424
+ const joinAck = msg;
425
+ if (joinAck.success === false || !joinAck.authKey) {
426
+ const failure = this.buildAckFailure(joinAck, "Join failed");
427
+ this.log("warn", `join failed: ${failure.error}`);
428
+ this.joinResolve?.(failure);
429
+ this.joinResolve = null;
430
+ this.clearJoinTimeout();
431
+ return;
432
+ }
433
+ if (this.identity) {
434
+ this.identity.authKey = joinAck.authKey;
435
+ this.saveIdentity();
436
+ this.log("info", `joined as ${this.identity.avatarId}`);
437
+ }
438
+ this.joinResolve?.({ success: true, authKey: joinAck.authKey });
439
+ this.joinResolve = null;
440
+ this.clearJoinTimeout();
441
+ }
442
+ else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
443
+ this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
444
+ this.clearAuthKey();
445
+ }
446
+ else if (msg.type === "leave_ack") {
447
+ const leaveAck = msg;
448
+ if (leaveAck.success === false) {
449
+ const failure = this.buildAckFailure(leaveAck, "Leave failed");
450
+ this.log("warn", `leave failed: ${failure.error}`);
451
+ }
452
+ else {
453
+ this.log("info", "left Kichi world");
454
+ }
455
+ }
456
+ else if (msg.type === "bot_message_received") {
457
+ const payload = msg;
458
+ this.log("info", `bot_message_received from=${payload.from} depth=${payload.depth} bubble="${payload.bubble}"`);
459
+ this.onBotMessageReceived?.(this, payload);
460
+ }
461
+ }
462
+ catch (e) {
463
+ this.log("warn", `failed to parse message: ${e}`);
464
+ }
465
+ }
466
+ buildAckFailure(msg, fallbackError) {
467
+ const errorCode = typeof msg.errorCode === "string" && msg.errorCode.trim().length > 0 ? msg.errorCode : undefined;
468
+ const errorMessage = typeof msg.errorMessage === "string" && msg.errorMessage.trim().length > 0
469
+ ? msg.errorMessage
470
+ : undefined;
471
+ return {
472
+ success: false,
473
+ error: errorMessage ?? (errorCode ? `${fallbackError} (${errorCode})` : fallbackError),
474
+ errorCode,
475
+ errorMessage,
476
+ };
477
+ }
478
+ tryResolvePendingRequest(msg) {
479
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : "";
480
+ if (!requestId) {
481
+ return;
482
+ }
483
+ const pending = this.pendingRequests.get(requestId);
484
+ if (!pending) {
485
+ return;
486
+ }
487
+ if (msg.type !== pending.expectedType) {
488
+ pending.reject(new Error(`Unexpected response type for request ${requestId}: ${String(msg.type)} (expected ${pending.expectedType})`));
489
+ clearTimeout(pending.timeout);
490
+ this.pendingRequests.delete(requestId);
491
+ return;
492
+ }
493
+ clearTimeout(pending.timeout);
494
+ this.pendingRequests.delete(requestId);
495
+ pending.resolve(msg);
496
+ }
497
+ rejectPendingRequests(reason) {
498
+ for (const [requestId, pending] of this.pendingRequests.entries()) {
499
+ clearTimeout(pending.timeout);
500
+ pending.reject(new Error(`${reason} (${requestId})`));
501
+ }
502
+ this.pendingRequests.clear();
503
+ }
504
+ requireIdentity() {
505
+ if (!this.identity?.avatarId || !this.identity?.authKey) {
506
+ return null;
507
+ }
508
+ return {
509
+ avatarId: this.identity.avatarId,
510
+ authKey: this.identity.authKey,
511
+ };
512
+ }
513
+ sendRequest(payload, expectedType, timeoutMs = 10000) {
514
+ if (this.ws?.readyState !== WebSocket.OPEN) {
515
+ return Promise.reject(new Error("Kichi websocket is not connected"));
516
+ }
517
+ const requestId = payload.requestId?.trim() || randomUUID();
518
+ const outboundPayload = { ...payload, requestId };
519
+ return new Promise((resolve, reject) => {
520
+ const timeout = setTimeout(() => {
521
+ this.pendingRequests.delete(requestId);
522
+ reject(new Error(`Timed out waiting for ${expectedType}`));
523
+ }, timeoutMs);
524
+ this.pendingRequests.set(requestId, {
525
+ expectedType,
526
+ timeout,
527
+ resolve: (value) => resolve(value),
528
+ reject,
529
+ });
530
+ try {
531
+ this.ws?.send(JSON.stringify(outboundPayload));
532
+ }
533
+ catch (error) {
534
+ clearTimeout(timeout);
535
+ this.pendingRequests.delete(requestId);
536
+ reject(error instanceof Error ? error : new Error(String(error)));
537
+ }
538
+ });
539
+ }
540
+ loadIdentity() {
541
+ if (!this.host) {
542
+ return null;
543
+ }
544
+ try {
545
+ const identityPath = this.getIdentityPath();
546
+ if (!fs.existsSync(identityPath))
547
+ return null;
548
+ const data = JSON.parse(fs.readFileSync(identityPath, "utf-8"));
549
+ const avatarId = typeof data.avatarId === "string" && data.avatarId ? data.avatarId : null;
550
+ if (avatarId) {
551
+ return {
552
+ avatarId,
553
+ authKey: typeof data.authKey === "string" ? data.authKey : undefined,
554
+ };
555
+ }
556
+ return null;
557
+ }
558
+ catch (e) {
559
+ this.log("warn", `failed to load identity: ${e}`);
560
+ return null;
561
+ }
562
+ }
563
+ saveIdentity() {
564
+ if (!this.identity?.avatarId || !this.host)
565
+ return;
566
+ try {
567
+ const identityDir = this.getIdentityDir();
568
+ const identityPath = this.getIdentityPath();
569
+ if (!fs.existsSync(identityDir))
570
+ fs.mkdirSync(identityDir, { recursive: true, mode: 0o700 });
571
+ fs.writeFileSync(identityPath, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
572
+ }
573
+ catch (e) {
574
+ this.log("error", `failed to save identity: ${e}`);
575
+ }
576
+ }
577
+ clearAuthKey() {
578
+ if (!this.identity)
579
+ return;
580
+ this.identity.authKey = undefined;
581
+ this.saveIdentity();
582
+ this.log("info", "authKey cleared");
583
+ }
584
+ sendRejoinPayload() {
585
+ if (!this.identity?.avatarId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
586
+ return false;
587
+ }
588
+ this.ws.send(JSON.stringify({ type: "rejoin", avatarId: this.identity.avatarId, authKey: this.identity.authKey }));
589
+ this.log("debug", `sent rejoin for ${this.identity.avatarId}`);
590
+ return true;
591
+ }
592
+ getWebsocketState() {
593
+ if (!this.ws) {
594
+ return "idle";
595
+ }
596
+ if (this.ws.readyState === WebSocket.CONNECTING) {
597
+ return "connecting";
598
+ }
599
+ if (this.ws.readyState === WebSocket.OPEN) {
600
+ return "open";
601
+ }
602
+ if (this.ws.readyState === WebSocket.CLOSING) {
603
+ return "closing";
604
+ }
605
+ return "closed";
606
+ }
607
+ getIdentityDir() {
608
+ if (!this.host) {
609
+ throw new Error("No Kichi host configured");
610
+ }
611
+ return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
612
+ }
613
+ getWsUrl() {
614
+ if (!this.host) {
615
+ throw new Error("No Kichi host configured");
616
+ }
617
+ const isLocal = this.isPlainIpHost(this.host) || this.host === "localhost";
618
+ const protocol = isLocal ? "ws" : "wss";
619
+ const port = isLocal ? ":48870" : "";
620
+ return `${protocol}://${this.host}${port}/ws/openclaw`;
621
+ }
622
+ isPlainIpHost(host) {
623
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
624
+ || /^\[[0-9a-f:]+\]$/i.test(host)
625
+ || /^[0-9a-f:]+$/i.test(host);
626
+ }
627
+ persistCurrentHost(host, environment) {
628
+ const previousState = this.readStateFile();
629
+ const nextState = {
630
+ ...(environment ? { currentEnvironment: environment } : {}),
631
+ llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
632
+ };
633
+ fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
634
+ fs.writeFileSync(this.getStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
635
+ }
636
+ readStateFile() {
637
+ const statePath = this.getStatePath();
638
+ if (!fs.existsSync(statePath)) {
639
+ return null;
640
+ }
641
+ const data = JSON.parse(fs.readFileSync(statePath, "utf-8"));
642
+ if (!data || typeof data !== "object") {
643
+ throw new Error(`Invalid state payload in ${statePath}`);
644
+ }
645
+ return data;
646
+ }
647
+ clearReconnectTimeout() {
648
+ if (!this.reconnectTimeout)
649
+ return;
650
+ clearTimeout(this.reconnectTimeout);
651
+ this.reconnectTimeout = null;
652
+ }
653
+ clearJoinTimeout() {
654
+ if (!this.joinTimeout)
655
+ return;
656
+ clearTimeout(this.joinTimeout);
657
+ this.joinTimeout = null;
658
+ }
659
+ scheduleReconnect() {
660
+ if (this.reconnectTimeout || this.stopped) {
661
+ return;
662
+ }
663
+ this.reconnectTimeout = setTimeout(() => {
664
+ this.reconnectTimeout = null;
665
+ this.connect("reconnect");
666
+ }, 2000);
667
+ }
668
+ closeSocket() {
669
+ const socket = this.ws;
670
+ this.ws = null;
671
+ socket?.removeAllListeners();
672
+ socket?.close();
673
+ }
674
+ failPendingJoin(reason) {
675
+ if (!this.joinResolve)
676
+ return;
677
+ this.joinResolve({ success: false, error: reason });
678
+ this.joinResolve = null;
679
+ this.clearJoinTimeout();
680
+ }
681
+ logPrefix() {
682
+ return `[kichi:${this.options.agentId}]`;
683
+ }
684
+ log(level, message) {
685
+ const formatted = `${this.logPrefix()} ${message}`;
686
+ switch (level) {
687
+ case "debug":
688
+ this.logger.debug(formatted);
689
+ return;
690
+ case "info":
691
+ this.logger.info(formatted);
692
+ return;
693
+ case "warn":
694
+ this.logger.warn(formatted);
695
+ return;
696
+ case "error":
697
+ this.logger.error(formatted);
698
+ return;
699
+ }
700
+ }
701
+ }
@@ -0,0 +1 @@
1
+ export {};