@yahaha-studio/kichi-forwarder 0.1.2-beta.12 → 0.1.2-beta.14

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
  }
@@ -961,6 +962,10 @@ const plugin = {
961
962
  description: "Optional list of OpenClaw self-perceived personality tags",
962
963
  items: { type: "string" },
963
964
  },
965
+ source: {
966
+ type: "string",
967
+ description: "Optional join source identifier. Defaults to Kichi World join-source.json, then openclaw.",
968
+ },
964
969
  },
965
970
  required: ["botName", "bio"],
966
971
  },
@@ -974,6 +979,7 @@ const plugin = {
974
979
  let avatarId = params?.avatarId;
975
980
  const botName = params?.botName?.trim();
976
981
  const bio = params?.bio?.trim();
982
+ const rawSource = params?.source;
977
983
  const { tags, error: tagsError } = normalizeJoinTags(params?.tags);
978
984
  if (!avatarId) {
979
985
  avatarId = service.readSavedAvatarId() ?? undefined;
@@ -987,10 +993,22 @@ const plugin = {
987
993
  if (!bio) {
988
994
  return jsonResult({ success: false, error: "No bio" });
989
995
  }
996
+ let source;
997
+ try {
998
+ source = rawSource === undefined
999
+ ? service.readConfiguredJoinSource() ?? "openclaw"
1000
+ : trimOptionalString(rawSource);
1001
+ }
1002
+ catch (err) {
1003
+ return jsonResult({ success: false, error: err instanceof Error ? err.message : String(err) });
1004
+ }
1005
+ if (!source) {
1006
+ return jsonResult({ success: false, error: "source must be a non-empty string" });
1007
+ }
990
1008
  if (tagsError) {
991
1009
  return jsonResult({ success: false, error: tagsError });
992
1010
  }
993
- const result = await service.join(avatarId, botName, bio, tags ?? []);
1011
+ const result = await service.join(avatarId, botName, bio, tags ?? [], source);
994
1012
  if (result.success) {
995
1013
  return jsonResult({ success: true, authKey: result.authKey });
996
1014
  }
@@ -1221,6 +1239,59 @@ const plugin = {
1221
1239
  },
1222
1240
  });
1223
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" });
1224
1295
  api.registerTool((ctx) => ({
1225
1296
  name: "kichi_idle_plan",
1226
1297
  label: "kichi_idle_plan",
@@ -1449,7 +1520,7 @@ const plugin = {
1449
1520
  api.registerTool((ctx) => ({
1450
1521
  name: "kichi_query_status",
1451
1522
  label: "kichi_query_status",
1452
- 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.",
1453
1524
  parameters: {
1454
1525
  type: "object",
1455
1526
  properties: {
@@ -4,6 +4,8 @@ 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;
8
+ const JOIN_SOURCE_FILE_NAME = "join-source.json";
7
9
  export class KichiForwarderService {
8
10
  logger;
9
11
  options;
@@ -63,7 +65,7 @@ export class KichiForwarderService {
63
65
  }
64
66
  return this.getConnectionStatus();
65
67
  }
66
- async join(avatarId, botName, bio, tags) {
68
+ async join(avatarId, botName, bio, tags, source) {
67
69
  if (!this.host) {
68
70
  return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
69
71
  }
@@ -78,7 +80,7 @@ export class KichiForwarderService {
78
80
  this.identity = { avatarId };
79
81
  this.saveIdentity();
80
82
  this.joinResolve = resolve;
81
- const payload = { type: "join", avatarId, botName, bio, tags };
83
+ const payload = { type: "join", avatarId, botName, bio, tags, source };
82
84
  const sendJoin = () => this.ws?.send(JSON.stringify(payload));
83
85
  if (this.ws?.readyState === WebSocket.OPEN) {
84
86
  sendJoin();
@@ -176,6 +178,30 @@ export class KichiForwarderService {
176
178
  this.ws.send(JSON.stringify(payload));
177
179
  return true;
178
180
  }
181
+ async sendGlance(target, durationSeconds = DEFAULT_GLANCE_DURATION_SECONDS, requestId) {
182
+ const identity = this.requireIdentity();
183
+ if (!identity) {
184
+ throw new Error("Missing Kichi identity");
185
+ }
186
+ if (this.ws?.readyState !== WebSocket.OPEN) {
187
+ throw new Error("Kichi websocket is not connected");
188
+ }
189
+ if (target !== "camera") {
190
+ throw new Error("target must be camera");
191
+ }
192
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
193
+ throw new Error("duration must be a positive finite number");
194
+ }
195
+ const payload = {
196
+ type: "kichi_glance",
197
+ requestId: requestId?.trim() || randomUUID(),
198
+ avatarId: identity.avatarId,
199
+ authKey: identity.authKey,
200
+ target,
201
+ duration: durationSeconds,
202
+ };
203
+ return this.sendRequest(payload, "kichi_glance_ack", 5000);
204
+ }
179
205
  async queryStatus(requestId) {
180
206
  const identity = this.requireIdentity();
181
207
  if (!identity) {
@@ -274,6 +300,24 @@ export class KichiForwarderService {
274
300
  getRuntimeDir() {
275
301
  return this.options.runtimeDir;
276
302
  }
303
+ getJoinSourcePath() {
304
+ return path.join(this.getKichiWorldRootDir(), JOIN_SOURCE_FILE_NAME);
305
+ }
306
+ readConfiguredJoinSource() {
307
+ const sourcePath = this.getJoinSourcePath();
308
+ if (!fs.existsSync(sourcePath)) {
309
+ return null;
310
+ }
311
+ const data = JSON.parse(fs.readFileSync(sourcePath, "utf-8"));
312
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
313
+ throw new Error(`${JOIN_SOURCE_FILE_NAME} must contain a JSON object`);
314
+ }
315
+ const source = data.source;
316
+ if (typeof source !== "string" || !source.trim()) {
317
+ throw new Error(`${JOIN_SOURCE_FILE_NAME} must contain a non-empty string source`);
318
+ }
319
+ return source.trim();
320
+ }
277
321
  getStatePath() {
278
322
  return path.join(this.options.runtimeDir, "state.json");
279
323
  }
@@ -621,6 +665,9 @@ export class KichiForwarderService {
621
665
  }
622
666
  return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
623
667
  }
668
+ getKichiWorldRootDir() {
669
+ return path.dirname(path.dirname(this.options.runtimeDir));
670
+ }
624
671
  getWsUrl() {
625
672
  if (!this.host) {
626
673
  throw new Error("No Kichi host configured");
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
 
@@ -1179,6 +1180,10 @@ const plugin = {
1179
1180
  description: "Optional list of OpenClaw self-perceived personality tags",
1180
1181
  items: { type: "string" },
1181
1182
  },
1183
+ source: {
1184
+ type: "string",
1185
+ description: "Optional join source identifier. Defaults to Kichi World join-source.json, then openclaw.",
1186
+ },
1182
1187
  },
1183
1188
  required: ["botName", "bio"],
1184
1189
  },
@@ -1192,6 +1197,7 @@ const plugin = {
1192
1197
  let avatarId = (params as { avatarId?: string } | null)?.avatarId;
1193
1198
  const botName = (params as { botName?: string } | null)?.botName?.trim();
1194
1199
  const bio = (params as { bio?: string } | null)?.bio?.trim();
1200
+ const rawSource = (params as { source?: unknown } | null)?.source;
1195
1201
  const { tags, error: tagsError } = normalizeJoinTags(
1196
1202
  (params as { tags?: unknown } | null)?.tags,
1197
1203
  );
@@ -1207,10 +1213,21 @@ const plugin = {
1207
1213
  if (!bio) {
1208
1214
  return jsonResult({ success: false, error: "No bio" });
1209
1215
  }
1216
+ let source: string | null | undefined;
1217
+ try {
1218
+ source = rawSource === undefined
1219
+ ? service.readConfiguredJoinSource() ?? "openclaw"
1220
+ : trimOptionalString(rawSource);
1221
+ } catch (err) {
1222
+ return jsonResult({ success: false, error: err instanceof Error ? err.message : String(err) });
1223
+ }
1224
+ if (!source) {
1225
+ return jsonResult({ success: false, error: "source must be a non-empty string" });
1226
+ }
1210
1227
  if (tagsError) {
1211
1228
  return jsonResult({ success: false, error: tagsError });
1212
1229
  }
1213
- const result = await service.join(avatarId, botName, bio, tags ?? []);
1230
+ const result = await service.join(avatarId, botName, bio, tags ?? [], source);
1214
1231
  if (result.success) {
1215
1232
  return jsonResult({ success: true, authKey: result.authKey });
1216
1233
  }
@@ -1462,6 +1479,71 @@ const plugin = {
1462
1479
  });
1463
1480
  },
1464
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
+
1465
1547
  api.registerTool((ctx) => ({
1466
1548
  name: "kichi_idle_plan",
1467
1549
  label: "kichi_idle_plan",
@@ -1701,7 +1783,7 @@ const plugin = {
1701
1783
  name: "kichi_query_status",
1702
1784
  label: "kichi_query_status",
1703
1785
  description:
1704
- "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.",
1705
1787
  parameters: {
1706
1788
  type: "object",
1707
1789
  properties: {
@@ -1979,4 +2061,3 @@ const plugin = {
1979
2061
  };
1980
2062
 
1981
2063
  export default plugin;
1982
-
@@ -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.12",
5
+ "version": "0.1.2-beta.14",
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.12",
3
+ "version": "0.1.2-beta.14",
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,6 +36,8 @@ 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;
40
+ const JOIN_SOURCE_FILE_NAME = "join-source.json";
36
41
 
37
42
  type AckFailureResult = {
38
43
  success: false;
@@ -138,6 +143,7 @@ export class KichiForwarderService {
138
143
  botName: string,
139
144
  bio: string,
140
145
  tags: string[],
146
+ source: string,
141
147
  ): Promise<JoinResult> {
142
148
  if (!this.host) {
143
149
  return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
@@ -153,7 +159,7 @@ export class KichiForwarderService {
153
159
  this.identity = { avatarId };
154
160
  this.saveIdentity();
155
161
  this.joinResolve = resolve;
156
- const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags };
162
+ const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags, source };
157
163
  const sendJoin = () => this.ws?.send(JSON.stringify(payload));
158
164
  if (this.ws?.readyState === WebSocket.OPEN) {
159
165
  sendJoin();
@@ -262,6 +268,36 @@ export class KichiForwarderService {
262
268
  return true;
263
269
  }
264
270
 
271
+ async sendGlance(
272
+ target: GlanceTarget,
273
+ durationSeconds = DEFAULT_GLANCE_DURATION_SECONDS,
274
+ requestId?: string,
275
+ ): Promise<GlanceAckPayload> {
276
+ const identity = this.requireIdentity();
277
+ if (!identity) {
278
+ throw new Error("Missing Kichi identity");
279
+ }
280
+ if (this.ws?.readyState !== WebSocket.OPEN) {
281
+ throw new Error("Kichi websocket is not connected");
282
+ }
283
+ if (target !== "camera") {
284
+ throw new Error("target must be camera");
285
+ }
286
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
287
+ throw new Error("duration must be a positive finite number");
288
+ }
289
+
290
+ const payload: GlancePayload = {
291
+ type: "kichi_glance",
292
+ requestId: requestId?.trim() || randomUUID(),
293
+ avatarId: identity.avatarId,
294
+ authKey: identity.authKey,
295
+ target,
296
+ duration: durationSeconds,
297
+ };
298
+ return this.sendRequest<GlanceAckPayload>(payload, "kichi_glance_ack", 5000);
299
+ }
300
+
265
301
  async queryStatus(requestId?: string): Promise<QueryStatusResultPayload> {
266
302
  const identity = this.requireIdentity();
267
303
  if (!identity) {
@@ -381,6 +417,29 @@ export class KichiForwarderService {
381
417
  return this.options.runtimeDir;
382
418
  }
383
419
 
420
+ getJoinSourcePath(): string {
421
+ return path.join(this.getKichiWorldRootDir(), JOIN_SOURCE_FILE_NAME);
422
+ }
423
+
424
+ readConfiguredJoinSource(): string | null {
425
+ const sourcePath = this.getJoinSourcePath();
426
+ if (!fs.existsSync(sourcePath)) {
427
+ return null;
428
+ }
429
+
430
+ const data = JSON.parse(fs.readFileSync(sourcePath, "utf-8")) as unknown;
431
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
432
+ throw new Error(`${JOIN_SOURCE_FILE_NAME} must contain a JSON object`);
433
+ }
434
+
435
+ const source = (data as { source?: unknown }).source;
436
+ if (typeof source !== "string" || !source.trim()) {
437
+ throw new Error(`${JOIN_SOURCE_FILE_NAME} must contain a non-empty string source`);
438
+ }
439
+
440
+ return source.trim();
441
+ }
442
+
384
443
  getStatePath(): string {
385
444
  return path.join(this.options.runtimeDir, "state.json");
386
445
  }
@@ -765,6 +824,10 @@ export class KichiForwarderService {
765
824
  return path.join(this.options.runtimeDir, "hosts", encodeURIComponent(this.host));
766
825
  }
767
826
 
827
+ private getKichiWorldRootDir(): string {
828
+ return path.dirname(path.dirname(this.options.runtimeDir));
829
+ }
830
+
768
831
  private getWsUrl(): string {
769
832
  if (!this.host) {
770
833
  throw new Error("No Kichi host configured");
package/src/types.ts CHANGED
@@ -86,6 +86,7 @@ export type JoinPayload = {
86
86
  botName: string;
87
87
  bio: string;
88
88
  tags: string[];
89
+ source: string;
89
90
  };
90
91
 
91
92
  export type JoinAckPayload = {
@@ -131,6 +132,23 @@ export type StatusAckPayload = {
131
132
  warning?: string;
132
133
  };
133
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
+
134
152
  export type HookNotifyType = "message_received" | "before_send_message";
135
153
 
136
154
  export type HookNotifyPayload = {