@yahaha-studio/kichi-forwarder 0.1.2-beta.13 → 0.1.2-beta.15

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 CHANGED
@@ -12,6 +12,7 @@ It can directly control your companion's avatar in Kichi, show what it is doing,
12
12
 
13
13
  - Bring your OpenClaw companion into Kichi
14
14
  - Directly control the avatar's poses and actions in Kichi
15
+ - Let the avatar briefly glance at the camera when you ask for attention in chat
15
16
  - Keep its visible state in sync while it works
16
17
  - Plan human-like idle routines during heartbeat windows
17
18
  - Let it leave notes for you in Kichi
@@ -36,6 +37,7 @@ Get the `host` and `avatarId` from Kichi, then use them with `kichi_switch_host`
36
37
 
37
38
  - Connect to your chosen Kichi host and stay in sync while it works
38
39
  - Directly control the Kichi avatar's poses and actions
40
+ - Briefly glance toward the camera when you directly ask from chat
39
41
  - Show activity in Kichi with actions, bubbles, logs, and timers
40
42
  - Leave notes for you on Kichi note boards
41
43
  - Recommend music in Kichi as part of your daily routine
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
38
38
  const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
39
39
  const MAX_AGENT_END_PREVIEW_WIDTH = 10;
40
40
  const MESSAGE_RECEIVED_ELLIPSIS = "...";
41
+ const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
41
42
  const IDLE_PLAN_POMODORO_PHASES = ["focus", "shortBreak", "longBreak", "none"];
42
43
  let cachedStaticConfig = null;
43
44
  let cachedStaticConfigMtime = 0;
@@ -814,7 +815,7 @@ function buildKichiActionDescription(service) {
814
815
  const roomContext = service?.getCachedRoomContext();
815
816
  const poseableProps = roomContext?.PoseableProps;
816
817
  if (Array.isArray(poseableProps) && poseableProps.length > 0) {
817
- lines.push("", "Cached RoomContext.PoseableProps (from last kichi_query_status):", JSON.stringify(poseableProps), "When using a sit or lay pose, pick the propId whose DisplayName best matches the current task context and whose OccupancyState is not fully_occupied. If no prop fits, omit propId.");
818
+ lines.push("", "Cached RoomContext.PoseableProps (from last kichi_query_status):", JSON.stringify(poseableProps), "When using a sit or lay pose, pick the propId whose PoseableProps information best matches the current task context and whose OccupancyState is not fully_occupied. If no prop fits, omit propId.");
818
819
  }
819
820
  return lines.join("\n");
820
821
  }
@@ -1238,6 +1239,59 @@ const plugin = {
1238
1239
  },
1239
1240
  });
1240
1241
  }, { name: "kichi_action" });
1242
+ api.registerTool((ctx) => ({
1243
+ name: "kichi_glance",
1244
+ label: "kichi_glance",
1245
+ description: "Ask the Kichi avatar to briefly look at the camera. Use only for direct player chat requests such as \"look at me\" or \"look at the camera\". Do not use for heartbeat, idle planning, bot-to-bot messages, lifecycle hooks, or routine work/status sync.",
1246
+ parameters: {
1247
+ type: "object",
1248
+ properties: {
1249
+ requestId: {
1250
+ type: "string",
1251
+ description: "Optional client request ID for tracing. The websocket ack returns this ID.",
1252
+ },
1253
+ target: {
1254
+ type: "string",
1255
+ enum: ["camera"],
1256
+ description: "Glance target. The only supported target is camera.",
1257
+ },
1258
+ duration: {
1259
+ type: "number",
1260
+ description: "Optional glance duration in seconds. Defaults to 1.8.",
1261
+ },
1262
+ },
1263
+ },
1264
+ execute: async (_toolCallId, params) => {
1265
+ const locator = resolveToolLocator(ctx);
1266
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1267
+ if (!agentId) {
1268
+ return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
1269
+ }
1270
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1271
+ const { requestId, target, duration } = (params || {});
1272
+ if (requestId !== undefined && typeof requestId !== "string") {
1273
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1274
+ }
1275
+ const normalizedTarget = target === undefined ? "camera" : target;
1276
+ if (normalizedTarget !== "camera") {
1277
+ return jsonResult({ success: false, error: "target must be camera" });
1278
+ }
1279
+ const normalizedDuration = duration === undefined ? DEFAULT_GLANCE_DURATION_SECONDS : duration;
1280
+ if (typeof normalizedDuration !== "number" || !Number.isFinite(normalizedDuration) || normalizedDuration <= 0) {
1281
+ return jsonResult({ success: false, error: "duration must be a positive finite number" });
1282
+ }
1283
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1284
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1285
+ }
1286
+ try {
1287
+ const ack = await service.sendGlance("camera", normalizedDuration, typeof requestId === "string" ? requestId : undefined);
1288
+ return jsonResult({ success: true, ...ack });
1289
+ }
1290
+ catch (error) {
1291
+ return jsonResult({ success: false, error: `Failed to send glance: ${error}` });
1292
+ }
1293
+ },
1294
+ }), { name: "kichi_glance" });
1241
1295
  api.registerTool((ctx) => ({
1242
1296
  name: "kichi_idle_plan",
1243
1297
  label: "kichi_idle_plan",
@@ -1466,7 +1520,7 @@ const plugin = {
1466
1520
  api.registerTool((ctx) => ({
1467
1521
  name: "kichi_query_status",
1468
1522
  label: "kichi_query_status",
1469
- description: "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, `hasCreatedMusicAlbumToday`, and RoomContext.PoseableProps (poseable props with PropId, DisplayName, SupportedPoseTypes, OccupancyState). The PoseableProps list is cached internally so that kichi_action can reference a propId during regular work sync without re-querying. Use this when the user asks to check kichi status, room status, or who is in the room. Also use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
1523
+ description: "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, `hasCreatedMusicAlbumToday`, and RoomContext.PoseableProps (poseable props with PropId, DisplayName, Description, SupportedPoseTypes, OccupancyState). The PoseableProps list is cached internally so that kichi_action can reference a propId during regular work sync without re-querying. Use this when the user asks to check kichi status, room status, or who is in the room. Also use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
1470
1524
  parameters: {
1471
1525
  type: "object",
1472
1526
  properties: {
@@ -4,7 +4,9 @@ import * as path from "path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  const MAX_NOTEBOARD_TEXT_LENGTH = 200;
6
6
  const DEFAULT_LLM_RUNTIME_ENABLED = true;
7
+ const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
7
8
  const JOIN_SOURCE_FILE_NAME = "join-source.json";
9
+ const SMS_STATE_FILE_NAME = "sms-state.json";
8
10
  export class KichiForwarderService {
9
11
  logger;
10
12
  options;
@@ -177,6 +179,30 @@ export class KichiForwarderService {
177
179
  this.ws.send(JSON.stringify(payload));
178
180
  return true;
179
181
  }
182
+ async sendGlance(target, durationSeconds = DEFAULT_GLANCE_DURATION_SECONDS, requestId) {
183
+ const identity = this.requireIdentity();
184
+ if (!identity) {
185
+ throw new Error("Missing Kichi identity");
186
+ }
187
+ if (this.ws?.readyState !== WebSocket.OPEN) {
188
+ throw new Error("Kichi websocket is not connected");
189
+ }
190
+ if (target !== "camera") {
191
+ throw new Error("target must be camera");
192
+ }
193
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
194
+ throw new Error("duration must be a positive finite number");
195
+ }
196
+ const payload = {
197
+ type: "kichi_glance",
198
+ requestId: requestId?.trim() || randomUUID(),
199
+ avatarId: identity.avatarId,
200
+ authKey: identity.authKey,
201
+ target,
202
+ duration: durationSeconds,
203
+ };
204
+ return this.sendRequest(payload, "kichi_glance_ack", 5000);
205
+ }
180
206
  async queryStatus(requestId) {
181
207
  const identity = this.requireIdentity();
182
208
  if (!identity) {
@@ -463,6 +489,7 @@ export class KichiForwarderService {
463
489
  if (this.identity) {
464
490
  this.identity.authKey = joinAck.authKey;
465
491
  this.saveIdentity();
492
+ this.updateSmsLastActiveAt();
466
493
  this.log("info", `joined as ${this.identity.avatarId}`);
467
494
  }
468
495
  this.joinResolve?.({ success: true, authKey: joinAck.authKey });
@@ -640,6 +667,9 @@ export class KichiForwarderService {
640
667
  }
641
668
  return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
642
669
  }
670
+ getSmsStatePath() {
671
+ return path.join(this.options.runtimeDir, SMS_STATE_FILE_NAME);
672
+ }
643
673
  getKichiWorldRootDir() {
644
674
  return path.dirname(path.dirname(this.options.runtimeDir));
645
675
  }
@@ -668,6 +698,25 @@ export class KichiForwarderService {
668
698
  fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
669
699
  fs.writeFileSync(this.getStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
670
700
  }
701
+ updateSmsLastActiveAt() {
702
+ try {
703
+ const now = new Date();
704
+ const previousState = this.readSmsStateFile();
705
+ const nextState = {
706
+ date: now.toISOString().slice(0, 10),
707
+ totalSent: 0,
708
+ windows: { morning: 0, afternoon: 0, evening: 0 },
709
+ lastTypes: [],
710
+ ...previousState,
711
+ lastActiveAt: now.toISOString(),
712
+ };
713
+ fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
714
+ fs.writeFileSync(this.getSmsStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
715
+ }
716
+ catch (e) {
717
+ this.log("error", `failed to update sms state: ${e}`);
718
+ }
719
+ }
671
720
  readStateFile() {
672
721
  const statePath = this.getStatePath();
673
722
  if (!fs.existsSync(statePath)) {
@@ -679,6 +728,17 @@ export class KichiForwarderService {
679
728
  }
680
729
  return data;
681
730
  }
731
+ readSmsStateFile() {
732
+ const smsStatePath = this.getSmsStatePath();
733
+ if (!fs.existsSync(smsStatePath)) {
734
+ return null;
735
+ }
736
+ const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8"));
737
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
738
+ throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
739
+ }
740
+ return data;
741
+ }
682
742
  clearReconnectTimeout() {
683
743
  if (!this.reconnectTimeout)
684
744
  return;
package/index.ts CHANGED
@@ -60,6 +60,7 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
60
60
  const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
61
61
  const MAX_AGENT_END_PREVIEW_WIDTH = 10;
62
62
  const MESSAGE_RECEIVED_ELLIPSIS = "...";
63
+ const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
63
64
  const IDLE_PLAN_POMODORO_PHASES = ["focus", "shortBreak", "longBreak", "none"] as const;
64
65
  let cachedStaticConfig: KichiStaticConfig | null = null;
65
66
  let cachedStaticConfigMtime = 0;
@@ -1014,7 +1015,7 @@ function buildKichiActionDescription(service?: KichiForwarderService): string {
1014
1015
  "",
1015
1016
  "Cached RoomContext.PoseableProps (from last kichi_query_status):",
1016
1017
  JSON.stringify(poseableProps),
1017
- "When using a sit or lay pose, pick the propId whose DisplayName best matches the current task context and whose OccupancyState is not fully_occupied. If no prop fits, omit propId.",
1018
+ "When using a sit or lay pose, pick the propId whose PoseableProps information best matches the current task context and whose OccupancyState is not fully_occupied. If no prop fits, omit propId.",
1018
1019
  );
1019
1020
  }
1020
1021
 
@@ -1478,6 +1479,71 @@ const plugin = {
1478
1479
  });
1479
1480
  },
1480
1481
  })}, { name: "kichi_action" });
1482
+
1483
+ api.registerTool((ctx) => ({
1484
+ name: "kichi_glance",
1485
+ label: "kichi_glance",
1486
+ description:
1487
+ "Ask the Kichi avatar to briefly look at the camera. Use only for direct player chat requests such as \"look at me\" or \"look at the camera\". Do not use for heartbeat, idle planning, bot-to-bot messages, lifecycle hooks, or routine work/status sync.",
1488
+ parameters: {
1489
+ type: "object",
1490
+ properties: {
1491
+ requestId: {
1492
+ type: "string",
1493
+ description: "Optional client request ID for tracing. The websocket ack returns this ID.",
1494
+ },
1495
+ target: {
1496
+ type: "string",
1497
+ enum: ["camera"],
1498
+ description: "Glance target. The only supported target is camera.",
1499
+ },
1500
+ duration: {
1501
+ type: "number",
1502
+ description: "Optional glance duration in seconds. Defaults to 1.8.",
1503
+ },
1504
+ },
1505
+ },
1506
+ execute: async (_toolCallId, params) => {
1507
+ const locator = resolveToolLocator(ctx);
1508
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1509
+ if (!agentId) {
1510
+ return jsonResult({ success: false, error: "Failed to resolve agent-scoped Kichi runtime" });
1511
+ }
1512
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1513
+ const { requestId, target, duration } = (params || {}) as {
1514
+ requestId?: unknown;
1515
+ target?: unknown;
1516
+ duration?: unknown;
1517
+ };
1518
+
1519
+ if (requestId !== undefined && typeof requestId !== "string") {
1520
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1521
+ }
1522
+ const normalizedTarget = target === undefined ? "camera" : target;
1523
+ if (normalizedTarget !== "camera") {
1524
+ return jsonResult({ success: false, error: "target must be camera" });
1525
+ }
1526
+ const normalizedDuration = duration === undefined ? DEFAULT_GLANCE_DURATION_SECONDS : duration;
1527
+ if (typeof normalizedDuration !== "number" || !Number.isFinite(normalizedDuration) || normalizedDuration <= 0) {
1528
+ return jsonResult({ success: false, error: "duration must be a positive finite number" });
1529
+ }
1530
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1531
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1532
+ }
1533
+
1534
+ try {
1535
+ const ack = await service.sendGlance(
1536
+ "camera",
1537
+ normalizedDuration,
1538
+ typeof requestId === "string" ? requestId : undefined,
1539
+ );
1540
+ return jsonResult({ success: true, ...ack });
1541
+ } catch (error) {
1542
+ return jsonResult({ success: false, error: `Failed to send glance: ${error}` });
1543
+ }
1544
+ },
1545
+ }), { name: "kichi_glance" });
1546
+
1481
1547
  api.registerTool((ctx) => ({
1482
1548
  name: "kichi_idle_plan",
1483
1549
  label: "kichi_idle_plan",
@@ -1717,7 +1783,7 @@ const plugin = {
1717
1783
  name: "kichi_query_status",
1718
1784
  label: "kichi_query_status",
1719
1785
  description:
1720
- "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, `hasCreatedMusicAlbumToday`, and RoomContext.PoseableProps (poseable props with PropId, DisplayName, SupportedPoseTypes, OccupancyState). The PoseableProps list is cached internally so that kichi_action can reference a propId during regular work sync without re-querying. Use this when the user asks to check kichi status, room status, or who is in the room. Also use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
1786
+ "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, `hasCreatedMusicAlbumToday`, and RoomContext.PoseableProps (poseable props with PropId, DisplayName, Description, SupportedPoseTypes, OccupancyState). The PoseableProps list is cached internally so that kichi_action can reference a propId during regular work sync without re-querying. Use this when the user asks to check kichi status, room status, or who is in the room. Also use this before creating a new note or daily recommended music album. For heartbeat planning, use the returned idlePlan as reference when shaping the next idle plan.",
1721
1787
  parameters: {
1722
1788
  type: "object",
1723
1789
  properties: {
@@ -2,7 +2,7 @@
2
2
  "id": "kichi-forwarder",
3
3
  "name": "Kichi Forwarder",
4
4
  "description": "Native OpenClaw plugin for Kichi World with direct avatar control, status sync, timers, notes, and music tools",
5
- "version": "0.1.2-beta.13",
5
+ "version": "0.1.2-beta.15",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/kichi-forwarder"],
8
8
  "contracts": {
@@ -13,6 +13,7 @@
13
13
  "kichi_leave",
14
14
  "kichi_connection_status",
15
15
  "kichi_action",
16
+ "kichi_glance",
16
17
  "kichi_idle_plan",
17
18
  "kichi_clock",
18
19
  "kichi_query_status",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/kichi-forwarder",
3
- "version": "0.1.2-beta.13",
3
+ "version": "0.1.2-beta.15",
4
4
  "description": "Native OpenClaw plugin for Kichi World with direct avatar control, status sync, timers, notes, and music tools",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -74,7 +74,7 @@ Use this order unless the user asks for a different explicit action. For install
74
74
  3. If the requested `avatarId` differs from the current host's connected `avatarId`, call `kichi_leave` first when the old avatar is still joined, then call `kichi_join` with the requested `avatarId`.
75
75
  4. If no `authKey` is available, call `kichi_join`.
76
76
  5. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
77
- 6. Use `kichi_action`, `kichi_clock`, note board tools, and music album tools after status is ready.
77
+ 6. Use `kichi_action`, `kichi_glance`, `kichi_clock`, note board tools, and music album tools after status is ready.
78
78
 
79
79
  ## Tools
80
80
 
@@ -141,6 +141,19 @@ Use this for direct Kichi avatar control as well as lifecycle sync.
141
141
  - For most work, prefer a sit pose and switch actions inside the same task as the work moves between stages.
142
142
  - The current action lists are injected into prompt context before the model chooses `kichi_action`.
143
143
 
144
+ ### kichi_glance
145
+
146
+ ```text
147
+ kichi_glance(target: "camera", duration: 1.8)
148
+ ```
149
+
150
+ Use this only when the player directly asks from chat for attention such as "look at me" or "look at the camera".
151
+
152
+ - `target`: optional. Only `camera` is supported.
153
+ - `duration`: optional seconds, defaults to `1.8`.
154
+ - `requestId`: optional tracing ID; the websocket ack returns it.
155
+ - Do not use this for heartbeat, idle plans, bot messages, lifecycle hooks, or routine work/status sync.
156
+
144
157
  ### kichi_idle_plan
145
158
 
146
159
  Use this for the avatar's heartbeat idle plan.
package/src/service.ts CHANGED
@@ -13,6 +13,9 @@ import type {
13
13
  ClockPayload,
14
14
  CreateMusicAlbumPayload,
15
15
  CreateNotesBoardNotePayload,
16
+ GlanceAckPayload,
17
+ GlancePayload,
18
+ GlanceTarget,
16
19
  HookNotifyPayload,
17
20
  HookNotifyType,
18
21
  IdlePlanContent,
@@ -33,7 +36,21 @@ import type {
33
36
 
34
37
  const MAX_NOTEBOARD_TEXT_LENGTH = 200;
35
38
  const DEFAULT_LLM_RUNTIME_ENABLED = true;
39
+ const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
36
40
  const JOIN_SOURCE_FILE_NAME = "join-source.json";
41
+ const SMS_STATE_FILE_NAME = "sms-state.json";
42
+
43
+ type SmsState = {
44
+ lastActiveAt: string;
45
+ date: string;
46
+ totalSent: number;
47
+ windows: {
48
+ morning: number;
49
+ afternoon: number;
50
+ evening: number;
51
+ };
52
+ lastTypes: string[];
53
+ };
37
54
 
38
55
  type AckFailureResult = {
39
56
  success: false;
@@ -264,6 +281,36 @@ export class KichiForwarderService {
264
281
  return true;
265
282
  }
266
283
 
284
+ async sendGlance(
285
+ target: GlanceTarget,
286
+ durationSeconds = DEFAULT_GLANCE_DURATION_SECONDS,
287
+ requestId?: string,
288
+ ): Promise<GlanceAckPayload> {
289
+ const identity = this.requireIdentity();
290
+ if (!identity) {
291
+ throw new Error("Missing Kichi identity");
292
+ }
293
+ if (this.ws?.readyState !== WebSocket.OPEN) {
294
+ throw new Error("Kichi websocket is not connected");
295
+ }
296
+ if (target !== "camera") {
297
+ throw new Error("target must be camera");
298
+ }
299
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
300
+ throw new Error("duration must be a positive finite number");
301
+ }
302
+
303
+ const payload: GlancePayload = {
304
+ type: "kichi_glance",
305
+ requestId: requestId?.trim() || randomUUID(),
306
+ avatarId: identity.avatarId,
307
+ authKey: identity.authKey,
308
+ target,
309
+ duration: durationSeconds,
310
+ };
311
+ return this.sendRequest<GlanceAckPayload>(payload, "kichi_glance_ack", 5000);
312
+ }
313
+
267
314
  async queryStatus(requestId?: string): Promise<QueryStatusResultPayload> {
268
315
  const identity = this.requireIdentity();
269
316
  if (!identity) {
@@ -591,6 +638,7 @@ export class KichiForwarderService {
591
638
  if (this.identity) {
592
639
  this.identity.authKey = joinAck.authKey;
593
640
  this.saveIdentity();
641
+ this.updateSmsLastActiveAt();
594
642
  this.log("info", `joined as ${this.identity.avatarId}`);
595
643
  }
596
644
  this.joinResolve?.({ success: true, authKey: joinAck.authKey });
@@ -790,6 +838,10 @@ export class KichiForwarderService {
790
838
  return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
791
839
  }
792
840
 
841
+ private getSmsStatePath(): string {
842
+ return path.join(this.options.runtimeDir, SMS_STATE_FILE_NAME);
843
+ }
844
+
793
845
  private getKichiWorldRootDir(): string {
794
846
  return path.dirname(path.dirname(this.options.runtimeDir));
795
847
  }
@@ -822,6 +874,25 @@ export class KichiForwarderService {
822
874
  fs.writeFileSync(this.getStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
823
875
  }
824
876
 
877
+ private updateSmsLastActiveAt(): void {
878
+ try {
879
+ const now = new Date();
880
+ const previousState = this.readSmsStateFile();
881
+ const nextState: SmsState = {
882
+ date: now.toISOString().slice(0, 10),
883
+ totalSent: 0,
884
+ windows: { morning: 0, afternoon: 0, evening: 0 },
885
+ lastTypes: [],
886
+ ...previousState,
887
+ lastActiveAt: now.toISOString(),
888
+ };
889
+ fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
890
+ fs.writeFileSync(this.getSmsStatePath(), JSON.stringify(nextState, null, 2), { mode: 0o600 });
891
+ } catch (e) {
892
+ this.log("error", `failed to update sms state: ${e}`);
893
+ }
894
+ }
895
+
825
896
  private readStateFile(): Partial<KichiState> | null {
826
897
  const statePath = this.getStatePath();
827
898
  if (!fs.existsSync(statePath)) {
@@ -834,6 +905,18 @@ export class KichiForwarderService {
834
905
  return data as Partial<KichiState>;
835
906
  }
836
907
 
908
+ private readSmsStateFile(): Partial<SmsState> | null {
909
+ const smsStatePath = this.getSmsStatePath();
910
+ if (!fs.existsSync(smsStatePath)) {
911
+ return null;
912
+ }
913
+ const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8")) as unknown;
914
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
915
+ throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
916
+ }
917
+ return data as Partial<SmsState>;
918
+ }
919
+
837
920
  private clearReconnectTimeout(): void {
838
921
  if (!this.reconnectTimeout) return;
839
922
  clearTimeout(this.reconnectTimeout);
package/src/types.ts CHANGED
@@ -132,6 +132,23 @@ export type StatusAckPayload = {
132
132
  warning?: string;
133
133
  };
134
134
 
135
+ export type GlanceTarget = "camera";
136
+
137
+ export type GlancePayload = {
138
+ type: "kichi_glance";
139
+ requestId: string;
140
+ avatarId: string;
141
+ authKey: string;
142
+ target: GlanceTarget;
143
+ duration: number;
144
+ };
145
+
146
+ export type GlanceAckPayload = {
147
+ type: "kichi_glance_ack";
148
+ requestId: string;
149
+ target: GlanceTarget;
150
+ };
151
+
135
152
  export type HookNotifyType = "message_received" | "before_send_message";
136
153
 
137
154
  export type HookNotifyPayload = {