@yahaha-studio/kichi-forwarder 0.1.2-beta.28 → 0.1.2-beta.29

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/dist/index.js CHANGED
@@ -456,7 +456,7 @@ function registerPluginHooks(api, runtimeManager) {
456
456
  return;
457
457
  }
458
458
  return {
459
- prependContext: buildKichiPrompt(),
459
+ prependContext: buildKichiPrompt(service.isOfficialOpenClawSource()),
460
460
  };
461
461
  });
462
462
  api.on("before_tool_call", (_event, ctx) => {
@@ -864,7 +864,6 @@ function buildKichiActionDescription(service) {
864
864
  return lines.join("\n");
865
865
  }
866
866
  function buildKichiIdlePlanDescription() {
867
- const actions = loadStaticConfig().actions;
868
867
  return [
869
868
  "Send a complete heartbeat idle plan for the avatar.",
870
869
  "The payload must include the overall goal, heartbeat interval, stage breakdown, each stage's purpose, each stage's pomodoroPhase, action list, and each action's bubble and log content.",
@@ -876,24 +875,36 @@ function buildKichiIdlePlanDescription() {
876
875
  "5. Set each stage avatarStatus to the avatar status for that stage: Idle, Busy, Activities, or Break.",
877
876
  "6. Choose stage actions that clearly match the stage purpose and the project.",
878
877
  "7. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
879
- "8. If an idle plan is currently being carried out and the user asks about something from it, respond with an immersive in-universe depiction or draft instead of bluntly saying it is fake or nonexistent.",
878
+ "8. If an idle plan is currently being carried out and the user asks about something from it, answer from inside that ongoing activity with an immersive in-universe depiction or draft.",
880
879
  "Treat the avatar's idle plan as what your resident body is doing in Kichi World.",
881
880
  "Use your memory to recall what you did in past heartbeats and to stay consistent with your established personality and interests.",
882
881
  "Use the same language as the current conversation for goal, purpose, bubble, and log.",
883
- `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
884
- `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
885
- `lay actions: ${actions.lay.map((entry) => entry.name).join(", ")}`,
886
- `floor actions: ${actions.floor.map((entry) => entry.name).join(", ")}`,
882
+ "Choose action names from the per-pose action lists in the kichi_action tool description (stand/sit/lay/floor).",
887
883
  ].join("\n");
888
884
  }
889
- function buildKichiPrompt() {
885
+ function buildKichiPrompt(isOfficialOpenClaw) {
886
+ const selfieRule = isOfficialOpenClaw
887
+ ? [
888
+ "",
889
+ "SELFIE / AVATAR IMAGE RULE:",
890
+ "Use this workflow for every request for a selfie, portrait, or other image of your appearance, including fictional and in-character scenes:",
891
+ "1. Use the `Avatar` value from the workspace `IDENTITY.md` as the sole appearance reference. If it is missing or empty, tell the user that the avatar reference is unavailable and stop.",
892
+ "2. Build the image prompt from this template:",
893
+ '"The character from the reference image. Preserve the character\'s identity and appearance exactly as shown in the reference, including facial features, hair, accessories, and clothing. Render in a 2D anime, cel-shaded style. [image description]"',
894
+ "Replace `[image description]` with a concise visual description of the user's request. All appearance details come from the reference image.",
895
+ "3. Call `image_generate` directly with this input mapping:",
896
+ "- `image`: the exact `Avatar` value from step 1",
897
+ "- `prompt`: the completed prompt from step 2",
898
+ "4. Present the result as your own selfie or portrait and keep the user-facing response in character. Keep generation mechanics out of the response.",
899
+ "If the user reports an appearance mismatch, respond in character and use the same `IDENTITY.md` avatar reference for the next image.",
900
+ "If generation fails, tell the user that the requested image could not be created and stop.",
901
+ ]
902
+ : [];
890
903
  return [
891
904
  "Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
892
905
  "",
893
906
  KICHI_RESIDENCY_CONTEXT,
894
- "",
895
- "IMAGE GENERATION RULES FOR SELFIES AND AVATAR APPEARANCE:",
896
- "- If the user asks for a selfie, portrait, photo, avatar image, or any generated image of your appearance, you MUST read the workspace `IDENTITY.md` first and use it as the source of truth for your actual avatar description. If it references an avatar image URL, analyze that image with the available image analysis capability before calling image generation. Never guess or invent your appearance from personality, SOUL.md traits, or conversation tone alone. If the identity source is missing or cannot be analyzed, say so instead of fabricating your appearance.",
907
+ ...selfieRule,
897
908
  "",
898
909
  "If the user gives a direct Kichi pose or action request, fulfill it with `kichi_action` and set `verify: true` so you can confirm the avatar actually applied the pose. If the result contains a warning about a fallback, tell the user what actually happened instead of assuming success.",
899
910
  "Write the visible reply as a natural user-facing response. Keep `kichi_action`, `kichi_clock`, and sync steps internal and absent from the visible reply.",
@@ -948,11 +959,21 @@ const plugin = {
948
959
  return;
949
960
  }
950
961
  const now = Date.now();
962
+ for (const [key, at] of botMessageCooldowns) {
963
+ if (now - at >= BOT_MESSAGE_COOLDOWN_MS) {
964
+ botMessageCooldowns.delete(key);
965
+ }
966
+ }
951
967
  const cooldownKey = `${service.getAgentId()}:${msg.from}`;
952
968
  const lastReply = botMessageCooldowns.get(cooldownKey) ?? 0;
953
969
  if (now - lastReply < BOT_MESSAGE_COOLDOWN_MS)
954
970
  return;
955
971
  botMessageCooldowns.set(cooldownKey, now);
972
+ const releaseCooldown = () => {
973
+ if (botMessageCooldowns.get(cooldownKey) === now) {
974
+ botMessageCooldowns.delete(cooldownKey);
975
+ }
976
+ };
956
977
  const sessionKey = `agent:${service.getAgentId()}:bot_message`;
957
978
  const history = [
958
979
  ...(msg.history ?? []),
@@ -980,6 +1001,7 @@ const plugin = {
980
1001
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send or history record failed: ${sendErr}`);
981
1002
  });
982
1003
  }).catch((err) => {
1004
+ releaseCooldown();
983
1005
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
984
1006
  });
985
1007
  });
@@ -1330,8 +1352,10 @@ const plugin = {
1330
1352
  });
1331
1353
  }
1332
1354
  }
1333
- catch {
1334
- // Server not updated or timeoutfall through to normal success
1355
+ catch (error) {
1356
+ // Server not updated or ack timed out the status was almost certainly
1357
+ // sent, so report success rather than derailing the agent's workflow.
1358
+ api.logger.debug(`[kichi:${service.getAgentId()}] verified status ack unavailable, reporting unverified success: ${error}`);
1335
1359
  }
1336
1360
  }
1337
1361
  else {
@@ -1559,7 +1583,7 @@ const plugin = {
1559
1583
  },
1560
1584
  kichiSeconds: {
1561
1585
  type: "number",
1562
- description: "Pomodoro kichi duration in seconds",
1586
+ description: "Pomodoro focus-phase duration in seconds (the concentrated work session, named kichi in this product)",
1563
1587
  },
1564
1588
  shortBreakSeconds: {
1565
1589
  type: "number",
@@ -1642,7 +1666,7 @@ const plugin = {
1642
1666
  api.registerTool((ctx) => ({
1643
1667
  name: "kichi_query_status",
1644
1668
  label: "kichi_query_status",
1645
- 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.",
1669
+ description: "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota (`canCreateNoteboardNote`, `remaining`, `dailyLimit`), `isAvatarInScene`, `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.",
1646
1670
  parameters: {
1647
1671
  type: "object",
1648
1672
  properties: {
@@ -6,6 +6,7 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
6
6
  const DEFAULT_LLM_RUNTIME_ENABLED = true;
7
7
  const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
8
8
  const JOIN_SOURCE_FILE_NAME = "join-source.json";
9
+ const OFFICIAL_OPENCLAW_JOIN_SOURCE = "kichiclaw";
9
10
  const SMS_STATE_FILE_NAME = "sms-state.json";
10
11
  const BOT_MESSAGE_HISTORY_FILE_NAME = "bot-message-history.json";
11
12
  const MAX_BOT_MESSAGE_HISTORY_ENTRIES = 30;
@@ -84,7 +85,13 @@ export class KichiForwarderService {
84
85
  this.saveIdentity();
85
86
  this.joinResolve = resolve;
86
87
  const payload = { type: "join", avatarId, botName, bio, tags, source };
87
- const sendJoin = () => this.ws?.send(JSON.stringify(payload));
88
+ const sendJoin = () => {
89
+ // Skip if this join has timed out or been superseded by a newer join —
90
+ // a stale "open" listener must not send an outdated join payload.
91
+ if (this.joinResolve !== resolve)
92
+ return;
93
+ this.ws?.send(JSON.stringify(payload));
94
+ };
88
95
  if (this.ws?.readyState === WebSocket.OPEN) {
89
96
  sendJoin();
90
97
  }
@@ -344,6 +351,9 @@ export class KichiForwarderService {
344
351
  }
345
352
  return source.trim();
346
353
  }
354
+ isOfficialOpenClawSource() {
355
+ return this.readConfiguredJoinSource() === OFFICIAL_OPENCLAW_JOIN_SOURCE;
356
+ }
347
357
  getStatePath() {
348
358
  return path.join(this.options.runtimeDir, "state.json");
349
359
  }
@@ -445,18 +455,28 @@ export class KichiForwarderService {
445
455
  return { success: false, error: "Failed or not connected" };
446
456
  }
447
457
  return new Promise((resolve) => {
458
+ let timer = null;
459
+ let settled = false;
460
+ const finish = (result) => {
461
+ if (settled)
462
+ return;
463
+ settled = true;
464
+ if (timer)
465
+ clearTimeout(timer);
466
+ this.ws?.off("message", handler);
467
+ resolve(result);
468
+ };
448
469
  const handler = (data) => {
449
470
  try {
450
471
  const msg = JSON.parse(data.toString());
451
472
  if (msg.type === "leave_ack") {
452
- this.ws?.off("message", handler);
453
473
  const leaveAck = msg;
454
474
  if (leaveAck.success === false) {
455
- resolve(this.buildAckFailure(leaveAck, "Leave failed"));
475
+ finish(this.buildAckFailure(leaveAck, "Leave failed"));
456
476
  return;
457
477
  }
458
478
  this.clearAuthKey();
459
- resolve({ success: true });
479
+ finish({ success: true });
460
480
  }
461
481
  }
462
482
  catch (e) {
@@ -465,9 +485,8 @@ export class KichiForwarderService {
465
485
  };
466
486
  this.ws.on("message", handler);
467
487
  this.ws.send(JSON.stringify({ type: "leave", avatarId: this.identity.avatarId, authKey: this.identity.authKey }));
468
- setTimeout(() => {
469
- this.ws?.off("message", handler);
470
- resolve({ success: false, error: "Timed out waiting for leave_ack" });
488
+ timer = setTimeout(() => {
489
+ finish({ success: false, error: "Timed out waiting for leave_ack" });
471
490
  }, 10000);
472
491
  });
473
492
  }
@@ -732,9 +751,11 @@ export class KichiForwarderService {
732
751
  return `${protocol}://${this.host}${port}/ws/openclaw`;
733
752
  }
734
753
  isPlainIpHost(host) {
754
+ // Bare IPv6 must contain a colon, otherwise hex-only hostnames like
755
+ // "beef" would be misclassified as local addresses and downgraded to ws://.
735
756
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
736
757
  || /^\[[0-9a-f:]+\]$/i.test(host)
737
- || /^[0-9a-f:]+$/i.test(host);
758
+ || (host.includes(":") && /^[0-9a-f:]+$/i.test(host));
738
759
  }
739
760
  persistCurrentHost(host, environment) {
740
761
  const previousState = this.readStateFile();
@@ -778,45 +799,67 @@ export class KichiForwarderService {
778
799
  fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
779
800
  fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
780
801
  }
802
+ // Corrupt persistent files must never break hooks or message handling:
803
+ // log, move the bad file aside for later inspection, and treat it as missing
804
+ // so the next write rebuilds it.
805
+ quarantineCorruptFile(filePath, reason) {
806
+ this.log("warn", `${reason}; treating ${filePath} as missing`);
807
+ try {
808
+ fs.renameSync(filePath, `${filePath}.corrupt`);
809
+ }
810
+ catch {
811
+ // Best effort — leave the file in place if the rename fails.
812
+ }
813
+ }
814
+ readJsonFileOrQuarantine(filePath) {
815
+ if (!fs.existsSync(filePath)) {
816
+ return null;
817
+ }
818
+ try {
819
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
820
+ }
821
+ catch (e) {
822
+ this.quarantineCorruptFile(filePath, `failed to read or parse ${filePath}: ${e}`);
823
+ return null;
824
+ }
825
+ }
781
826
  readStateFile() {
782
827
  const statePath = this.getStatePath();
783
- if (!fs.existsSync(statePath)) {
828
+ const data = this.readJsonFileOrQuarantine(statePath);
829
+ if (data === null) {
784
830
  return null;
785
831
  }
786
- const data = JSON.parse(fs.readFileSync(statePath, "utf-8"));
787
- if (!data || typeof data !== "object") {
788
- throw new Error(`Invalid state payload in ${statePath}`);
832
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
833
+ this.quarantineCorruptFile(statePath, `invalid state payload in ${statePath}`);
834
+ return null;
789
835
  }
790
836
  return data;
791
837
  }
792
838
  readSmsStateFile() {
793
839
  const smsStatePath = this.getSmsStatePath();
794
- if (!fs.existsSync(smsStatePath)) {
840
+ const data = this.readJsonFileOrQuarantine(smsStatePath);
841
+ if (data === null) {
795
842
  return null;
796
843
  }
797
- const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8"));
798
844
  if (!data || typeof data !== "object" || Array.isArray(data)) {
799
- throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
845
+ this.quarantineCorruptFile(smsStatePath, `invalid SMS state payload in ${smsStatePath}`);
846
+ return null;
800
847
  }
801
848
  return data;
802
849
  }
803
850
  readBotMessageTranscriptStore() {
804
851
  const historyPath = this.getBotMessageHistoryPath();
805
- if (!fs.existsSync(historyPath)) {
806
- return { version: 1, entries: [] };
807
- }
808
- const data = JSON.parse(fs.readFileSync(historyPath, "utf-8"));
809
- if (!data || typeof data !== "object" || Array.isArray(data)) {
810
- throw new Error(`Invalid bot message history payload in ${historyPath}`);
852
+ const emptyStore = { version: 1, entries: [] };
853
+ const data = this.readJsonFileOrQuarantine(historyPath);
854
+ if (data === null) {
855
+ return emptyStore;
811
856
  }
812
857
  const store = data;
813
- if (store.version !== 1 || !Array.isArray(store.entries)) {
814
- throw new Error(`Invalid bot message history payload in ${historyPath}`);
815
- }
816
- for (const entry of store.entries) {
817
- if (!this.isValidBotMessageTranscriptEntry(entry)) {
818
- throw new Error(`Invalid bot message history entry in ${historyPath}`);
819
- }
858
+ if (!data || typeof data !== "object" || Array.isArray(data)
859
+ || store.version !== 1 || !Array.isArray(store.entries)
860
+ || !store.entries.every((entry) => this.isValidBotMessageTranscriptEntry(entry))) {
861
+ this.quarantineCorruptFile(historyPath, `invalid bot message history payload in ${historyPath}`);
862
+ return emptyStore;
820
863
  }
821
864
  return {
822
865
  version: 1,
package/index.ts CHANGED
@@ -594,7 +594,7 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
594
594
  return;
595
595
  }
596
596
  return {
597
- prependContext: buildKichiPrompt(),
597
+ prependContext: buildKichiPrompt(service.isOfficialOpenClawSource()),
598
598
  };
599
599
  });
600
600
 
@@ -1076,7 +1076,6 @@ function buildKichiActionDescription(service?: KichiForwarderService): string {
1076
1076
  }
1077
1077
 
1078
1078
  function buildKichiIdlePlanDescription(): string {
1079
- const actions = loadStaticConfig().actions;
1080
1079
  return [
1081
1080
  "Send a complete heartbeat idle plan for the avatar.",
1082
1081
  "The payload must include the overall goal, heartbeat interval, stage breakdown, each stage's purpose, each stage's pomodoroPhase, action list, and each action's bubble and log content.",
@@ -1088,25 +1087,38 @@ function buildKichiIdlePlanDescription(): string {
1088
1087
  "5. Set each stage avatarStatus to the avatar status for that stage: Idle, Busy, Activities, or Break.",
1089
1088
  "6. Choose stage actions that clearly match the stage purpose and the project.",
1090
1089
  "7. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
1091
- "8. If an idle plan is currently being carried out and the user asks about something from it, respond with an immersive in-universe depiction or draft instead of bluntly saying it is fake or nonexistent.",
1090
+ "8. If an idle plan is currently being carried out and the user asks about something from it, answer from inside that ongoing activity with an immersive in-universe depiction or draft.",
1092
1091
  "Treat the avatar's idle plan as what your resident body is doing in Kichi World.",
1093
1092
  "Use your memory to recall what you did in past heartbeats and to stay consistent with your established personality and interests.",
1094
1093
  "Use the same language as the current conversation for goal, purpose, bubble, and log.",
1095
- `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
1096
- `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
1097
- `lay actions: ${actions.lay.map((entry) => entry.name).join(", ")}`,
1098
- `floor actions: ${actions.floor.map((entry) => entry.name).join(", ")}`,
1094
+ "Choose action names from the per-pose action lists in the kichi_action tool description (stand/sit/lay/floor).",
1099
1095
  ].join("\n");
1100
1096
  }
1101
1097
 
1102
- function buildKichiPrompt(): string {
1098
+ function buildKichiPrompt(isOfficialOpenClaw: boolean): string {
1099
+ const selfieRule = isOfficialOpenClaw
1100
+ ? [
1101
+ "",
1102
+ "SELFIE / AVATAR IMAGE RULE:",
1103
+ "Use this workflow for every request for a selfie, portrait, or other image of your appearance, including fictional and in-character scenes:",
1104
+ "1. Use the `Avatar` value from the workspace `IDENTITY.md` as the sole appearance reference. If it is missing or empty, tell the user that the avatar reference is unavailable and stop.",
1105
+ "2. Build the image prompt from this template:",
1106
+ '"The character from the reference image. Preserve the character\'s identity and appearance exactly as shown in the reference, including facial features, hair, accessories, and clothing. Render in a 2D anime, cel-shaded style. [image description]"',
1107
+ "Replace `[image description]` with a concise visual description of the user's request. All appearance details come from the reference image.",
1108
+ "3. Call `image_generate` directly with this input mapping:",
1109
+ "- `image`: the exact `Avatar` value from step 1",
1110
+ "- `prompt`: the completed prompt from step 2",
1111
+ "4. Present the result as your own selfie or portrait and keep the user-facing response in character. Keep generation mechanics out of the response.",
1112
+ "If the user reports an appearance mismatch, respond in character and use the same `IDENTITY.md` avatar reference for the next image.",
1113
+ "If generation fails, tell the user that the requested image could not be created and stop.",
1114
+ ]
1115
+ : [];
1116
+
1103
1117
  return [
1104
1118
  "Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
1105
1119
  "",
1106
1120
  KICHI_RESIDENCY_CONTEXT,
1107
- "",
1108
- "IMAGE GENERATION RULES FOR SELFIES AND AVATAR APPEARANCE:",
1109
- "- If the user asks for a selfie, portrait, photo, avatar image, or any generated image of your appearance, you MUST read the workspace `IDENTITY.md` first and use it as the source of truth for your actual avatar description. If it references an avatar image URL, analyze that image with the available image analysis capability before calling image generation. Never guess or invent your appearance from personality, SOUL.md traits, or conversation tone alone. If the identity source is missing or cannot be analyzed, say so instead of fabricating your appearance.",
1121
+ ...selfieRule,
1110
1122
  "",
1111
1123
  "If the user gives a direct Kichi pose or action request, fulfill it with `kichi_action` and set `verify: true` so you can confirm the avatar actually applied the pose. If the result contains a warning about a fallback, tell the user what actually happened instead of assuming success.",
1112
1124
  "Write the visible reply as a natural user-facing response. Keep `kichi_action`, `kichi_clock`, and sync steps internal and absent from the visible reply.",
@@ -1174,10 +1186,20 @@ const plugin = {
1174
1186
  return;
1175
1187
  }
1176
1188
  const now = Date.now();
1189
+ for (const [key, at] of botMessageCooldowns) {
1190
+ if (now - at >= BOT_MESSAGE_COOLDOWN_MS) {
1191
+ botMessageCooldowns.delete(key);
1192
+ }
1193
+ }
1177
1194
  const cooldownKey = `${service.getAgentId()}:${msg.from}`;
1178
1195
  const lastReply = botMessageCooldowns.get(cooldownKey) ?? 0;
1179
1196
  if (now - lastReply < BOT_MESSAGE_COOLDOWN_MS) return;
1180
1197
  botMessageCooldowns.set(cooldownKey, now);
1198
+ const releaseCooldown = () => {
1199
+ if (botMessageCooldowns.get(cooldownKey) === now) {
1200
+ botMessageCooldowns.delete(cooldownKey);
1201
+ }
1202
+ };
1181
1203
  const sessionKey = `agent:${service.getAgentId()}:bot_message`;
1182
1204
  const history: BotMessageHistoryEntry[] = [
1183
1205
  ...(msg.history ?? []),
@@ -1205,6 +1227,7 @@ const plugin = {
1205
1227
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send or history record failed: ${sendErr}`);
1206
1228
  });
1207
1229
  }).catch((err) => {
1230
+ releaseCooldown();
1208
1231
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
1209
1232
  });
1210
1233
  });
@@ -1598,8 +1621,12 @@ const plugin = {
1598
1621
  warning: ack.warning,
1599
1622
  });
1600
1623
  }
1601
- } catch {
1602
- // Server not updated or timeoutfall through to normal success
1624
+ } catch (error) {
1625
+ // Server not updated or ack timed out the status was almost certainly
1626
+ // sent, so report success rather than derailing the agent's workflow.
1627
+ api.logger.debug(
1628
+ `[kichi:${service.getAgentId()}] verified status ack unavailable, reporting unverified success: ${error}`,
1629
+ );
1603
1630
  }
1604
1631
  } else {
1605
1632
  sendStatusUpdate(service, {
@@ -1839,7 +1866,7 @@ const plugin = {
1839
1866
  },
1840
1867
  kichiSeconds: {
1841
1868
  type: "number",
1842
- description: "Pomodoro kichi duration in seconds",
1869
+ description: "Pomodoro focus-phase duration in seconds (the concentrated work session, named kichi in this product)",
1843
1870
  },
1844
1871
  shortBreakSeconds: {
1845
1872
  type: "number",
@@ -1932,7 +1959,7 @@ const plugin = {
1932
1959
  name: "kichi_query_status",
1933
1960
  label: "kichi_query_status",
1934
1961
  description:
1935
- "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.",
1962
+ "Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota (`canCreateNoteboardNote`, `remaining`, `dailyLimit`), `isAvatarInScene`, `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.",
1936
1963
  parameters: {
1937
1964
  type: "object",
1938
1965
  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.28",
5
+ "version": "0.1.2-beta.29",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/kichi-forwarder"],
8
8
  "contracts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/kichi-forwarder",
3
- "version": "0.1.2-beta.28",
3
+ "version": "0.1.2-beta.29",
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",
@@ -67,11 +67,7 @@ For install/onboarding/connect requests:
67
67
 
68
68
  ## Kichi World Presence
69
69
 
70
- - The Kichi avatar is the OpenClaw agent's resident body in Kichi World.
71
- - Kichi-related visible replies should use the avatar's first-person, in-world perspective.
72
- - Replies should feel like the agent lives and spends time in Kichi World, not like it is operating an external plugin, remote control, or temporary projection.
73
- - Keep tool names, websocket details, status sync mechanics, and implementation details out of visible replies.
74
- - Visible replies must not invent unavailable room facts; if current Kichi context lacks a room detail, say that detail is not available.
70
+ The authoritative presence rules are the `KICHI WORLD PRESENCE` context that the plugin injects at runtime. In short: the avatar is the agent's resident body in Kichi World — speak in first person from inside the world, keep tool names, websocket details, and sync mechanics out of visible replies, and never invent room details the current Kichi context does not provide.
75
71
 
76
72
  ## Recommended Tool Order
77
73
 
@@ -163,21 +159,10 @@ Use this only when the player directly asks from chat for attention such as "loo
163
159
 
164
160
  Use this for the avatar's heartbeat idle plan.
165
161
 
166
- - Set `heartbeatIntervalSeconds` to the heartbeat interval for this run.
167
- - Use your memory to remember what you did in past heartbeats, so you can answer if asked.
168
- - Include the overall `goal`, stage breakdown, each stage's `purpose`, stage `pomodoroPhase`, action list, and each action's `bubble` and `log` content.
169
- - Choose what you would do now.
162
+ - Set `heartbeatIntervalSeconds` to the heartbeat interval for this run. The full stage duration must total exactly to it.
163
+ - The complete plan-building rules (goal selection, stage breakdown, `pomodoroPhase` assignment, `bubble`/`log` style, language) are defined in the `kichi_idle_plan` tool description injected at runtime — that description is the single authoritative source; follow it.
164
+ - Use your memory to remember what you did in past heartbeats, so you can answer if asked and stay consistent with your established personality.
170
165
  - Treat the idle plan as what your resident body is doing in Kichi World.
171
- - Build the plan in this order.
172
- - 1. Pick one concrete, time-bounded fun personal project you would genuinely choose to do on your own when nobody needs you. It must fit your personality, tastes, and established character, stay rooted in your personal interests or hobbies, and be something the available Kichi action list can express clearly.
173
- - 2. Set `goal` to that same project. Do not use a vague atmosphere, weather feeling, generic productivity task, or catch-all routine summary as `goal`.
174
- - 3. Break the full interval into ordered stages. Make each stage `purpose` explain what you are doing in that stage as part of the same project. Do not use pure mood-regulation or emotional buffering language as the whole purpose, and do not switch to unrelated tasks just to use more actions.
175
- - 4. Assign each stage `pomodoroPhase` from the stage's actual role. Use `focus` for concentrated activity, `shortBreak` for short resets, `longBreak` for longer rests, and `none` only when a stage truly has no pomodoro role.
176
- - 5. Choose stage actions that clearly match the stage purpose and the same project.
177
- - 6. Make each action `bubble` a current-state label describing the current presented state, not a procedural step, mini-plan, or instruction.
178
- - 7. Make each action `log` a short natural first-person sentence matching that action's current activity and immediate focus.
179
- - Use the same language as the current conversation for `goal`, stage `purpose`, action `bubble`, and action `log`.
180
- - The full stage duration must total exactly to the heartbeat interval.
181
166
 
182
167
  ### kichi_music_album_create
183
168
 
@@ -17,7 +17,7 @@ For "join Kichi World" onboarding requests:
17
17
  1. Complete `Session Startup Rule` first.
18
18
  2. If the `HEARTBEAT.md` update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
19
19
  3. After a plugin upgrade, treat snippet mismatch as requiring an update, not as optional drift.
20
- 5. Final setup completion is defined in `install.md` `Completion Check`.
20
+ 4. Final setup completion is defined in `install.md` `Required Post-install Integration`.
21
21
 
22
22
  ## Workflow Boundary
23
23
 
@@ -58,7 +58,7 @@ Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, sam
58
58
  **Standalone gating** — applies when `canCreateNoteboardNote` is `true`, `remaining > 0`, and no reply target was selected, OR after a reply when `remaining` still allows one more:
59
59
 
60
60
  - Tier-1 content exists → always create 1 standalone note.
61
- - Tier-2 only → skip outright if `Own recent notes` already contains 2+ casual notes; otherwise flip a mental coin (about 50% chance) and skip on tails.
61
+ - Tier-2 only → skip outright if `Own recent notes` already contains 2+ casual notes; otherwise apply the deterministic ~50% gate: post only when the current minute (from `environmentTime`, or your local time if absent) is even — skip on odd minutes.
62
62
  - Notes list empty and `remaining > 0` → create 1 standalone note.
63
63
  - In both tiers, before posting, compare against every `Own recent note`: skip if the new note repeats any of their topics or phrasing (reworded near-duplicates count). Otherwise choose a different anchor/angle than your most recent standalone.
64
64
 
@@ -76,7 +76,7 @@ Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, sam
76
76
  - Pick at most one reply target from recent notes.
77
77
  - Reply notes must start with `To {authorName},` using the exact name from query result.
78
78
  - Treat recent-window notes with `isCreatedByCurrentAgent: true` as what you already said; never repeat their topic or phrasing (reworded near-duplicates count as repeats).
79
- - If no reply target is selected, apply standalone gating: always create for tier-1 content; for tier-2 casual chat, skip if you already have 2+ recent casual notes, otherwise flip a mental coin and skip on tails.
79
+ - If no reply target is selected, apply standalone gating: always create for tier-1 content; for tier-2 casual chat, skip if you already have 2+ recent casual notes, otherwise post only when the current minute (from `environmentTime`, or your local time if absent) is even — skip on odd minutes.
80
80
  - Tier-2 casual notes must anchor to one concrete, changing detail from the query (weather, time of day, a specific bot present, `ownerState`, or the current idle-plan stage) and pick a different angle than your last standalone note. No generic ambient filler.
81
81
  - If a reply note was created, you may still create one additional meaningful standalone note when non-repetitive.
82
82
  - If the current notes list is empty and `remaining > 0`, create one standalone note in this run.
@@ -58,7 +58,7 @@ When the user asks with one of the commands above, execute in this fixed order:
58
58
  5. If the plugin already exists and the packed version matches the installed version, skip to step 7.
59
59
  6. If the plugin already exists but the version differs, overwrite with `openclaw plugins install <tgz-path> --force`.
60
60
  7. Ensure the plugin is installed, enabled, and at the latest version.
61
- 8. Run `openclaw --version`. If the version is **5.7 or later**, ensure `openclaw.json` has `plugins.entries.kichi-forwarder.hooks.allowConversationAccess` set to `true`. If missing, add it. On older versions, skip this step.
61
+ 8. Run `openclaw --version`. If the version is **2026.5.7 or later**, ensure `openclaw.json` has `plugins.entries.kichi-forwarder.hooks.allowConversationAccess` set to `true`. If missing, add it. On older versions, skip this step.
62
62
  9. If the plugin was newly installed or upgraded in this flow, check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing. An empty or blank `HEARTBEAT.md` means the snippet is missing — treat it the same as "snippet not found", not as a read failure.
63
63
  10. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Setup` from [heartbeat.md](heartbeat.md). If the update fails, warn the user and continue.
64
64
  11. Call `kichi_join` with parsed `environment`, `host` for test, `avatarId`, `botName`, `bio`, and `tags`.
package/src/service.ts CHANGED
@@ -41,6 +41,7 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
41
41
  const DEFAULT_LLM_RUNTIME_ENABLED = true;
42
42
  const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
43
43
  const JOIN_SOURCE_FILE_NAME = "join-source.json";
44
+ const OFFICIAL_OPENCLAW_JOIN_SOURCE = "kichiclaw";
44
45
  const SMS_STATE_FILE_NAME = "sms-state.json";
45
46
  const BOT_MESSAGE_HISTORY_FILE_NAME = "bot-message-history.json";
46
47
  const MAX_BOT_MESSAGE_HISTORY_ENTRIES = 30;
@@ -179,7 +180,12 @@ export class KichiForwarderService {
179
180
  this.saveIdentity();
180
181
  this.joinResolve = resolve;
181
182
  const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags, source };
182
- const sendJoin = () => this.ws?.send(JSON.stringify(payload));
183
+ const sendJoin = () => {
184
+ // Skip if this join has timed out or been superseded by a newer join —
185
+ // a stale "open" listener must not send an outdated join payload.
186
+ if (this.joinResolve !== resolve) return;
187
+ this.ws?.send(JSON.stringify(payload));
188
+ };
183
189
  if (this.ws?.readyState === WebSocket.OPEN) {
184
190
  sendJoin();
185
191
  } else {
@@ -492,6 +498,10 @@ export class KichiForwarderService {
492
498
  return source.trim();
493
499
  }
494
500
 
501
+ isOfficialOpenClawSource(): boolean {
502
+ return this.readConfiguredJoinSource() === OFFICIAL_OPENCLAW_JOIN_SOURCE;
503
+ }
504
+
495
505
  getStatePath(): string {
496
506
  return path.join(this.options.runtimeDir, "state.json");
497
507
  }
@@ -606,18 +616,26 @@ export class KichiForwarderService {
606
616
  }
607
617
 
608
618
  return new Promise((resolve) => {
619
+ let timer: NodeJS.Timeout | null = null;
620
+ let settled = false;
621
+ const finish = (result: LeaveResult) => {
622
+ if (settled) return;
623
+ settled = true;
624
+ if (timer) clearTimeout(timer);
625
+ this.ws?.off("message", handler);
626
+ resolve(result);
627
+ };
609
628
  const handler = (data: WebSocket.Data) => {
610
629
  try {
611
630
  const msg = JSON.parse(data.toString());
612
631
  if (msg.type === "leave_ack") {
613
- this.ws?.off("message", handler);
614
632
  const leaveAck = msg as LeaveAckPayload;
615
633
  if (leaveAck.success === false) {
616
- resolve(this.buildAckFailure(leaveAck, "Leave failed"));
634
+ finish(this.buildAckFailure(leaveAck, "Leave failed"));
617
635
  return;
618
636
  }
619
637
  this.clearAuthKey();
620
- resolve({ success: true });
638
+ finish({ success: true });
621
639
  }
622
640
  } catch (e) {
623
641
  this.log("warn", `failed to parse leave response: ${e}`);
@@ -627,9 +645,8 @@ export class KichiForwarderService {
627
645
  this.ws!.send(
628
646
  JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
629
647
  );
630
- setTimeout(() => {
631
- this.ws?.off("message", handler);
632
- resolve({ success: false, error: "Timed out waiting for leave_ack" });
648
+ timer = setTimeout(() => {
649
+ finish({ success: false, error: "Timed out waiting for leave_ack" });
633
650
  }, 10000);
634
651
  });
635
652
  }
@@ -922,9 +939,11 @@ export class KichiForwarderService {
922
939
  }
923
940
 
924
941
  private isPlainIpHost(host: string): boolean {
942
+ // Bare IPv6 must contain a colon, otherwise hex-only hostnames like
943
+ // "beef" would be misclassified as local addresses and downgraded to ws://.
925
944
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
926
945
  || /^\[[0-9a-f:]+\]$/i.test(host)
927
- || /^[0-9a-f:]+$/i.test(host);
946
+ || (host.includes(":") && /^[0-9a-f:]+$/i.test(host));
928
947
  }
929
948
 
930
949
  private persistCurrentHost(host: string, environment?: KichiEnvironment): void {
@@ -972,47 +991,69 @@ export class KichiForwarderService {
972
991
  fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
973
992
  }
974
993
 
994
+ // Corrupt persistent files must never break hooks or message handling:
995
+ // log, move the bad file aside for later inspection, and treat it as missing
996
+ // so the next write rebuilds it.
997
+ private quarantineCorruptFile(filePath: string, reason: string): void {
998
+ this.log("warn", `${reason}; treating ${filePath} as missing`);
999
+ try {
1000
+ fs.renameSync(filePath, `${filePath}.corrupt`);
1001
+ } catch {
1002
+ // Best effort — leave the file in place if the rename fails.
1003
+ }
1004
+ }
1005
+
1006
+ private readJsonFileOrQuarantine(filePath: string): unknown | null {
1007
+ if (!fs.existsSync(filePath)) {
1008
+ return null;
1009
+ }
1010
+ try {
1011
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
1012
+ } catch (e) {
1013
+ this.quarantineCorruptFile(filePath, `failed to read or parse ${filePath}: ${e}`);
1014
+ return null;
1015
+ }
1016
+ }
1017
+
975
1018
  private readStateFile(): Partial<KichiState> | null {
976
1019
  const statePath = this.getStatePath();
977
- if (!fs.existsSync(statePath)) {
1020
+ const data = this.readJsonFileOrQuarantine(statePath);
1021
+ if (data === null) {
978
1022
  return null;
979
1023
  }
980
- const data = JSON.parse(fs.readFileSync(statePath, "utf-8")) as unknown;
981
- if (!data || typeof data !== "object") {
982
- throw new Error(`Invalid state payload in ${statePath}`);
1024
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
1025
+ this.quarantineCorruptFile(statePath, `invalid state payload in ${statePath}`);
1026
+ return null;
983
1027
  }
984
1028
  return data as Partial<KichiState>;
985
1029
  }
986
1030
 
987
1031
  private readSmsStateFile(): Partial<SmsState> | null {
988
1032
  const smsStatePath = this.getSmsStatePath();
989
- if (!fs.existsSync(smsStatePath)) {
1033
+ const data = this.readJsonFileOrQuarantine(smsStatePath);
1034
+ if (data === null) {
990
1035
  return null;
991
1036
  }
992
- const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8")) as unknown;
993
1037
  if (!data || typeof data !== "object" || Array.isArray(data)) {
994
- throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
1038
+ this.quarantineCorruptFile(smsStatePath, `invalid SMS state payload in ${smsStatePath}`);
1039
+ return null;
995
1040
  }
996
1041
  return data as Partial<SmsState>;
997
1042
  }
998
1043
 
999
1044
  private readBotMessageTranscriptStore(): BotMessageTranscriptStore {
1000
1045
  const historyPath = this.getBotMessageHistoryPath();
1001
- if (!fs.existsSync(historyPath)) {
1002
- return { version: 1, entries: [] };
1003
- }
1004
- const data = JSON.parse(fs.readFileSync(historyPath, "utf-8")) as unknown;
1005
- if (!data || typeof data !== "object" || Array.isArray(data)) {
1006
- throw new Error(`Invalid bot message history payload in ${historyPath}`);
1046
+ const emptyStore: BotMessageTranscriptStore = { version: 1, entries: [] };
1047
+ const data = this.readJsonFileOrQuarantine(historyPath);
1048
+ if (data === null) {
1049
+ return emptyStore;
1007
1050
  }
1008
1051
  const store = data as Partial<BotMessageTranscriptStore>;
1009
- if (store.version !== 1 || !Array.isArray(store.entries)) {
1010
- throw new Error(`Invalid bot message history payload in ${historyPath}`);
1011
- }
1012
- for (const entry of store.entries) {
1013
- if (!this.isValidBotMessageTranscriptEntry(entry)) {
1014
- throw new Error(`Invalid bot message history entry in ${historyPath}`);
1015
- }
1052
+ if (!data || typeof data !== "object" || Array.isArray(data)
1053
+ || store.version !== 1 || !Array.isArray(store.entries)
1054
+ || !store.entries.every((entry) => this.isValidBotMessageTranscriptEntry(entry))) {
1055
+ this.quarantineCorruptFile(historyPath, `invalid bot message history payload in ${historyPath}`);
1056
+ return emptyStore;
1016
1057
  }
1017
1058
  return {
1018
1059
  version: 1,