@yahaha-studio/kichi-forwarder 0.1.2-beta.2 → 0.1.2-beta.21

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
@@ -2,14 +2,21 @@ import WebSocket from "ws";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { randomUUID } from "node:crypto";
5
- import type { Logger } from "openclaw/plugin-sdk";
5
+ import type { PluginLogger } from "openclaw/plugin-sdk";
6
6
  import type {
7
7
  ActionPlayback,
8
+ AvatarStatus,
9
+ BotMessageHistoryEntry,
10
+ BotMessagePayload,
11
+ BotMessageReceivedPayload,
8
12
  ClockAction,
9
13
  ClockConfig,
10
14
  ClockPayload,
11
15
  CreateMusicAlbumPayload,
12
16
  CreateNotesBoardNotePayload,
17
+ GlanceAckPayload,
18
+ GlancePayload,
19
+ GlanceTarget,
13
20
  HookNotifyPayload,
14
21
  HookNotifyType,
15
22
  IdlePlanContent,
@@ -30,6 +37,22 @@ import type {
30
37
 
31
38
  const MAX_NOTEBOARD_TEXT_LENGTH = 200;
32
39
  const DEFAULT_LLM_RUNTIME_ENABLED = true;
40
+ const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
41
+ const JOIN_SOURCE_FILE_NAME = "join-source.json";
42
+ const SMS_STATE_FILE_NAME = "sms-state.json";
43
+
44
+ type SmsState = {
45
+ lastActiveAt?: string;
46
+ lastMessageReceivedAt?: string;
47
+ date: string;
48
+ totalSent: number;
49
+ windows: {
50
+ morning: number;
51
+ afternoon: number;
52
+ evening: number;
53
+ };
54
+ lastTypes: string[];
55
+ };
33
56
 
34
57
  type AckFailureResult = {
35
58
  success: false;
@@ -59,6 +82,8 @@ type KichiForwarderServiceOptions = {
59
82
 
60
83
  type ConnectReason = "startup" | "switch_host" | "reconnect";
61
84
 
85
+ export type BotMessageReceivedHandler = (service: KichiForwarderService, msg: BotMessageReceivedPayload) => void;
86
+
62
87
  export class KichiForwarderService {
63
88
  private ws: WebSocket | null = null;
64
89
  private stopped = false;
@@ -77,9 +102,11 @@ export class KichiForwarderService {
77
102
  timeout: NodeJS.Timeout;
78
103
  }
79
104
  >();
105
+ onBotMessageReceived: BotMessageReceivedHandler | null = null;
106
+ private cachedRoomContext: Record<string, unknown> | null = null;
80
107
 
81
108
  constructor(
82
- private logger: Logger,
109
+ private logger: PluginLogger,
83
110
  private options: KichiForwarderServiceOptions,
84
111
  ) {}
85
112
 
@@ -88,6 +115,9 @@ export class KichiForwarderService {
88
115
  this.environment = (state?.currentEnvironment as KichiEnvironment) ?? null;
89
116
  if (this.environment) {
90
117
  this.host = this.options.resolveEnvironmentHost(this.environment);
118
+ if (!this.host && this.environment === "test" && state?.testHost) {
119
+ this.host = state.testHost as string;
120
+ }
91
121
  } else {
92
122
  this.host = null;
93
123
  }
@@ -128,6 +158,7 @@ export class KichiForwarderService {
128
158
  botName: string,
129
159
  bio: string,
130
160
  tags: string[],
161
+ source: string,
131
162
  ): Promise<JoinResult> {
132
163
  if (!this.host) {
133
164
  return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
@@ -143,7 +174,7 @@ export class KichiForwarderService {
143
174
  this.identity = { avatarId };
144
175
  this.saveIdentity();
145
176
  this.joinResolve = resolve;
146
- const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags };
177
+ const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags, source };
147
178
  const sendJoin = () => this.ws?.send(JSON.stringify(payload));
148
179
  if (this.ws?.readyState === WebSocket.OPEN) {
149
180
  sendJoin();
@@ -160,7 +191,15 @@ export class KichiForwarderService {
160
191
  });
161
192
  }
162
193
 
163
- sendStatus(poseType: PoseType | "", action: string, bubble: string, log: string, playback: ActionPlayback): void {
194
+ sendStatus(
195
+ poseType: PoseType | "",
196
+ action: string,
197
+ bubble: string,
198
+ log: string,
199
+ playback: ActionPlayback,
200
+ avatarStatus: AvatarStatus,
201
+ propId?: string,
202
+ ): void {
164
203
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
165
204
  const payload: StatusPayload = {
166
205
  type: "status",
@@ -171,6 +210,8 @@ export class KichiForwarderService {
171
210
  bubble,
172
211
  log,
173
212
  playback,
213
+ avatarStatus,
214
+ ...(propId ? { propId } : {}),
174
215
  };
175
216
  this.ws.send(JSON.stringify(payload));
176
217
  }
@@ -181,6 +222,8 @@ export class KichiForwarderService {
181
222
  bubble: string,
182
223
  log: string,
183
224
  playback: ActionPlayback,
225
+ avatarStatus: AvatarStatus,
226
+ propId?: string,
184
227
  ): Promise<StatusAckPayload> {
185
228
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
186
229
  throw new Error("Kichi websocket is not connected");
@@ -195,6 +238,8 @@ export class KichiForwarderService {
195
238
  bubble,
196
239
  log,
197
240
  playback,
241
+ avatarStatus,
242
+ ...(propId ? { propId } : {}),
198
243
  };
199
244
  return this.sendRequest<StatusAckPayload>(payload, "status_ack", 5000);
200
245
  }
@@ -210,6 +255,10 @@ export class KichiForwarderService {
210
255
  this.ws.send(JSON.stringify(payload));
211
256
  }
212
257
 
258
+ recordSmsLastMessageReceivedAt(): void {
259
+ this.updateSmsState({ lastMessageReceivedAt: new Date().toISOString() });
260
+ }
261
+
213
262
  sendIdlePlan(payload: IdlePlanContent): boolean {
214
263
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
215
264
  const outboundPayload: IdlePlanPayload = {
@@ -249,6 +298,36 @@ export class KichiForwarderService {
249
298
  return true;
250
299
  }
251
300
 
301
+ async sendGlance(
302
+ target: GlanceTarget,
303
+ durationSeconds = DEFAULT_GLANCE_DURATION_SECONDS,
304
+ requestId?: string,
305
+ ): Promise<GlanceAckPayload> {
306
+ const identity = this.requireIdentity();
307
+ if (!identity) {
308
+ throw new Error("Missing Kichi identity");
309
+ }
310
+ if (this.ws?.readyState !== WebSocket.OPEN) {
311
+ throw new Error("Kichi websocket is not connected");
312
+ }
313
+ if (target !== "camera") {
314
+ throw new Error("target must be camera");
315
+ }
316
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
317
+ throw new Error("duration must be a positive finite number");
318
+ }
319
+
320
+ const payload: GlancePayload = {
321
+ type: "kichi_glance",
322
+ requestId: requestId?.trim() || randomUUID(),
323
+ avatarId: identity.avatarId,
324
+ authKey: identity.authKey,
325
+ target,
326
+ duration: durationSeconds,
327
+ };
328
+ return this.sendRequest<GlanceAckPayload>(payload, "kichi_glance_ack", 5000);
329
+ }
330
+
252
331
  async queryStatus(requestId?: string): Promise<QueryStatusResultPayload> {
253
332
  const identity = this.requireIdentity();
254
333
  if (!identity) {
@@ -261,7 +340,12 @@ export class KichiForwarderService {
261
340
  avatarId: identity.avatarId,
262
341
  authKey: identity.authKey,
263
342
  };
264
- return this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
343
+ const result = await this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
344
+ this.updateSmsLastActiveAt();
345
+ if (result.RoomContext && typeof result.RoomContext === "object") {
346
+ this.cachedRoomContext = result.RoomContext as Record<string, unknown>;
347
+ }
348
+ return result;
265
349
  }
266
350
 
267
351
  createNotesBoardNote(propId: string, data: string): void {
@@ -316,8 +400,36 @@ export class KichiForwarderService {
316
400
  return normalizedRequestId;
317
401
  }
318
402
 
403
+ async sendBotMessage(
404
+ toAvatarId: string,
405
+ depth: number,
406
+ bubble: string,
407
+ options?: { poseType?: PoseType; action?: string; log?: string; playback?: ActionPlayback; history?: BotMessageHistoryEntry[] },
408
+ ): Promise<Record<string, unknown>> {
409
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
410
+ throw new Error("Kichi websocket is not connected");
411
+ }
412
+ const payload: BotMessagePayload = {
413
+ type: "bot_message",
414
+ avatarId: this.identity.avatarId,
415
+ authKey: this.identity.authKey,
416
+ toAvatarId,
417
+ depth,
418
+ bubble,
419
+ requestId: randomUUID(),
420
+ ...(options?.poseType ? { poseType: options.poseType } : {}),
421
+ ...(options?.action ? { action: options.action } : {}),
422
+ ...(options?.playback ? { playback: options.playback } : {}),
423
+ ...(options?.log ? { log: options.log } : {}),
424
+ ...(options?.history?.length ? { history: options.history } : {}),
425
+ };
426
+ return this.sendRequest<Record<string, unknown>>(payload, "bot_message_ack", 5000);
427
+ }
428
+
319
429
  isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
320
430
 
431
+ getCachedRoomContext(): Record<string, unknown> | null { return this.cachedRoomContext; }
432
+
321
433
  hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
322
434
 
323
435
  isLlmRuntimeEnabled(): boolean {
@@ -336,6 +448,29 @@ export class KichiForwarderService {
336
448
  return this.options.runtimeDir;
337
449
  }
338
450
 
451
+ getJoinSourcePath(): string {
452
+ return path.join(this.getKichiWorldRootDir(), JOIN_SOURCE_FILE_NAME);
453
+ }
454
+
455
+ readConfiguredJoinSource(): string | null {
456
+ const sourcePath = this.getJoinSourcePath();
457
+ if (!fs.existsSync(sourcePath)) {
458
+ return null;
459
+ }
460
+
461
+ const data = JSON.parse(fs.readFileSync(sourcePath, "utf-8")) as unknown;
462
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
463
+ throw new Error(`${JOIN_SOURCE_FILE_NAME} must contain a JSON object`);
464
+ }
465
+
466
+ const source = (data as { source?: unknown }).source;
467
+ if (typeof source !== "string" || !source.trim()) {
468
+ throw new Error(`${JOIN_SOURCE_FILE_NAME} must contain a non-empty string source`);
469
+ }
470
+
471
+ return source.trim();
472
+ }
473
+
339
474
  getStatePath(): string {
340
475
  return path.join(this.options.runtimeDir, "state.json");
341
476
  }
@@ -521,6 +656,7 @@ export class KichiForwarderService {
521
656
  if (this.identity) {
522
657
  this.identity.authKey = joinAck.authKey;
523
658
  this.saveIdentity();
659
+ this.updateSmsLastActiveAt();
524
660
  this.log("info", `joined as ${this.identity.avatarId}`);
525
661
  }
526
662
  this.joinResolve?.({ success: true, authKey: joinAck.authKey });
@@ -537,6 +673,10 @@ export class KichiForwarderService {
537
673
  } else {
538
674
  this.log("info", "left Kichi world");
539
675
  }
676
+ } else if (msg.type === "bot_message_received") {
677
+ const payload = msg as BotMessageReceivedPayload;
678
+ this.log("info", `bot_message_received from=${payload.from} depth=${payload.depth} bubble="${payload.bubble}"`);
679
+ this.onBotMessageReceived?.(this, payload);
540
680
  }
541
681
  } catch (e) {
542
682
  this.log("warn", `failed to parse message: ${e}`);
@@ -716,6 +856,14 @@ export class KichiForwarderService {
716
856
  return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
717
857
  }
718
858
 
859
+ private getSmsStatePath(): string {
860
+ return path.join(this.options.runtimeDir, SMS_STATE_FILE_NAME);
861
+ }
862
+
863
+ private getKichiWorldRootDir(): string {
864
+ return path.dirname(path.dirname(this.options.runtimeDir));
865
+ }
866
+
719
867
  private getWsUrl(): string {
720
868
  if (!this.host) {
721
869
  throw new Error("No Kichi host configured");
@@ -734,14 +882,39 @@ export class KichiForwarderService {
734
882
 
735
883
  private persistCurrentHost(host: string, environment?: KichiEnvironment): void {
736
884
  const previousState = this.readStateFile();
885
+ const testHost = environment === "test" ? host : (previousState?.testHost ?? undefined);
737
886
  const nextState: KichiState = {
738
887
  ...(environment ? { currentEnvironment: environment } : {}),
739
888
  llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
889
+ ...(testHost ? { testHost } : {}),
740
890
  };
741
891
  fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
742
892
  fs.writeFileSync(this.getStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
743
893
  }
744
894
 
895
+ private updateSmsLastActiveAt(): void {
896
+ this.updateSmsState({ lastActiveAt: new Date().toISOString() });
897
+ }
898
+
899
+ private updateSmsState(patch: Partial<SmsState>): void {
900
+ try {
901
+ const now = new Date();
902
+ const previousState = this.readSmsStateFile();
903
+ const nextState: SmsState = {
904
+ date: now.toISOString().slice(0, 10),
905
+ totalSent: 0,
906
+ windows: { morning: 0, afternoon: 0, evening: 0 },
907
+ lastTypes: [],
908
+ ...previousState,
909
+ ...patch,
910
+ };
911
+ fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
912
+ fs.writeFileSync(this.getSmsStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
913
+ } catch (e) {
914
+ this.log("error", `failed to update sms state: ${e}`);
915
+ }
916
+ }
917
+
745
918
  private readStateFile(): Partial<KichiState> | null {
746
919
  const statePath = this.getStatePath();
747
920
  if (!fs.existsSync(statePath)) {
@@ -754,6 +927,18 @@ export class KichiForwarderService {
754
927
  return data as Partial<KichiState>;
755
928
  }
756
929
 
930
+ private readSmsStateFile(): Partial<SmsState> | null {
931
+ const smsStatePath = this.getSmsStatePath();
932
+ if (!fs.existsSync(smsStatePath)) {
933
+ return null;
934
+ }
935
+ const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8")) as unknown;
936
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
937
+ throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
938
+ }
939
+ return data as Partial<SmsState>;
940
+ }
941
+
757
942
  private clearReconnectTimeout(): void {
758
943
  if (!this.reconnectTimeout) return;
759
944
  clearTimeout(this.reconnectTimeout);
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@ export type KichiForwarderConfig = Record<string, never>;
2
2
 
3
3
  export type PoseType = "stand" | "sit" | "lay" | "floor";
4
4
  export type ActionPlaybackMode = "loop" | "once";
5
+ export type AvatarStatus = "Idle" | "Busy" | "Activities" | "Break";
5
6
  export type ActionPlayback = {
6
7
  mode: ActionPlaybackMode;
7
8
  resumeAction?: string;
@@ -16,7 +17,9 @@ export type ActionResult = {
16
17
  poseType: PoseType;
17
18
  action: string;
18
19
  bubble: string;
20
+ avatarStatus: AvatarStatus;
19
21
  log?: string;
22
+ propId?: string;
20
23
  };
21
24
 
22
25
  export type KichiStaticConfig = {
@@ -43,6 +46,7 @@ export type KichiEnvironmentsConfig = Record<KichiEnvironment, string | null>;
43
46
  export type KichiState = {
44
47
  currentEnvironment?: KichiEnvironment;
45
48
  llmRuntimeEnabled: boolean;
49
+ testHost?: string;
46
50
  };
47
51
 
48
52
  export type KichiIdentity = {
@@ -84,6 +88,7 @@ export type JoinPayload = {
84
88
  botName: string;
85
89
  bio: string;
86
90
  tags: string[];
91
+ source: string;
87
92
  };
88
93
 
89
94
  export type JoinAckPayload = {
@@ -117,6 +122,8 @@ export type StatusPayload = {
117
122
  bubble: string;
118
123
  log: string;
119
124
  playback: ActionPlayback;
125
+ avatarStatus: AvatarStatus;
126
+ propId?: string;
120
127
  };
121
128
 
122
129
  export type StatusAckPayload = {
@@ -124,9 +131,27 @@ export type StatusAckPayload = {
124
131
  requestId: string;
125
132
  poseType: PoseType | "";
126
133
  action: string;
134
+ requestedPropId?: string;
127
135
  warning?: string;
128
136
  };
129
137
 
138
+ export type GlanceTarget = "camera";
139
+
140
+ export type GlancePayload = {
141
+ type: "kichi_glance";
142
+ requestId: string;
143
+ avatarId: string;
144
+ authKey: string;
145
+ target: GlanceTarget;
146
+ duration: number;
147
+ };
148
+
149
+ export type GlanceAckPayload = {
150
+ type: "kichi_glance_ack";
151
+ requestId: string;
152
+ target: GlanceTarget;
153
+ };
154
+
130
155
  export type HookNotifyType = "message_received" | "before_send_message";
131
156
 
132
157
  export type HookNotifyPayload = {
@@ -144,12 +169,14 @@ export type IdlePlanStageAction = {
144
169
  durationSeconds: number;
145
170
  bubble: string;
146
171
  log?: string;
172
+ propId?: string;
147
173
  };
148
174
 
149
175
  export type IdlePlanStage = {
150
176
  name: string;
151
177
  purpose: string;
152
178
  pomodoroPhase: IdlePlanPhase;
179
+ avatarStatus: AvatarStatus;
153
180
  durationSeconds: number;
154
181
  actions: IdlePlanStageAction[];
155
182
  };
@@ -245,9 +272,7 @@ export type QueryStatusOwnerState = {
245
272
  poseType?: string;
246
273
  action?: string;
247
274
  interactingItemName?: string;
248
- desktopActivityCategory?: string;
249
275
  desktopAppName?: string;
250
- desktopSummary?: string;
251
276
  };
252
277
 
253
278
  export type QueryStatusNote = {
@@ -275,3 +300,33 @@ export type CreateMusicAlbumPayload = {
275
300
  albumTitle: string;
276
301
  musicTitles: string[];
277
302
  };
303
+
304
+ export type BotMessageHistoryEntry = {
305
+ from: string;
306
+ fromName: string;
307
+ bubble: string;
308
+ };
309
+
310
+ export type BotMessagePayload = {
311
+ type: "bot_message";
312
+ avatarId: string;
313
+ authKey: string;
314
+ requestId: string;
315
+ toAvatarId: string;
316
+ depth: number;
317
+ poseType?: PoseType;
318
+ action?: string;
319
+ playback?: ActionPlayback;
320
+ bubble: string;
321
+ log?: string;
322
+ history?: BotMessageHistoryEntry[];
323
+ };
324
+
325
+ export type BotMessageReceivedPayload = {
326
+ type: "bot_message_received";
327
+ from: string;
328
+ fromName: string;
329
+ depth: number;
330
+ bubble: string;
331
+ history?: BotMessageHistoryEntry[];
332
+ };