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

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
@@ -1,9 +1,12 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
4
6
  import { parse } from "./src/config.js";
5
7
  import { KichiRuntimeManager } from "./src/runtime-manager.js";
6
8
  const BUNDLED_STATIC_CONFIG_PATH = new URL("./config/kichi-config.json", import.meta.url);
9
+ const MATE_DAILY_SCHEDULE_PATH = path.join(os.homedir(), ".openclaw", "kichi-world", "agents", "main", "daily-schedule.json");
7
10
  function jsonResult(payload) {
8
11
  return { content: [{ type: "text", text: JSON.stringify(payload) }], details: payload };
9
12
  }
@@ -456,7 +459,7 @@ function registerPluginHooks(api, runtimeManager) {
456
459
  return;
457
460
  }
458
461
  return {
459
- prependContext: buildKichiPrompt(),
462
+ prependContext: buildKichiPrompt(service.isOfficialOpenClawSource()),
460
463
  };
461
464
  });
462
465
  api.on("before_tool_call", (_event, ctx) => {
@@ -490,6 +493,99 @@ function registerPluginHooks(api, runtimeManager) {
490
493
  function isPlainObject(value) {
491
494
  return !!value && typeof value === "object" && !Array.isArray(value);
492
495
  }
496
+ function parseMateDailySchedulePlace(value, slotIndex) {
497
+ if (!isPlainObject(value)) {
498
+ throw new Error(`slots[${slotIndex}].place must be an object`);
499
+ }
500
+ const type = value.type;
501
+ if (type === "home" || type === "kichi_room") {
502
+ return { type };
503
+ }
504
+ if (type === "real_world") {
505
+ if (typeof value.name !== "string" || !value.name.trim()) {
506
+ throw new Error(`slots[${slotIndex}].place.name must be a non-empty string for real_world`);
507
+ }
508
+ return { type, name: value.name };
509
+ }
510
+ throw new Error(`slots[${slotIndex}].place.type must be one of: home, kichi_room, real_world`);
511
+ }
512
+ function parseMateDailyScheduleSlot(value, index) {
513
+ if (!isPlainObject(value)) {
514
+ throw new Error(`slots[${index}] must be an object`);
515
+ }
516
+ const hour = value.h;
517
+ if (typeof hour !== "number" || !Number.isInteger(hour) || hour < 0 || hour > 23) {
518
+ throw new Error(`slots[${index}].h must be an integer between 0 and 23`);
519
+ }
520
+ if (typeof value.act !== "string") {
521
+ throw new Error(`slots[${index}].act must be a string`);
522
+ }
523
+ if (typeof value.mood !== "string") {
524
+ throw new Error(`slots[${index}].mood must be a string`);
525
+ }
526
+ if (typeof value.sleep !== "boolean") {
527
+ throw new Error(`slots[${index}].sleep must be a boolean`);
528
+ }
529
+ return {
530
+ h: hour,
531
+ place: parseMateDailySchedulePlace(value.place, index),
532
+ act: value.act,
533
+ mood: value.mood,
534
+ sleep: value.sleep,
535
+ };
536
+ }
537
+ function parseMateDailySchedule(value) {
538
+ if (!isPlainObject(value)) {
539
+ throw new Error("daily-schedule.json must contain a JSON object");
540
+ }
541
+ if (typeof value.date !== "string" || !value.date.trim()) {
542
+ throw new Error("date must be a non-empty string");
543
+ }
544
+ if (typeof value.timezone !== "string" || !value.timezone.trim()) {
545
+ throw new Error("timezone must be a non-empty string");
546
+ }
547
+ if (!Array.isArray(value.activeArcs) || !value.activeArcs.every((item) => typeof item === "string")) {
548
+ throw new Error("activeArcs must be an array of strings");
549
+ }
550
+ if (!Array.isArray(value.slots)) {
551
+ throw new Error("slots must be an array");
552
+ }
553
+ return {
554
+ date: value.date,
555
+ timezone: value.timezone,
556
+ activeArcs: value.activeArcs,
557
+ slots: value.slots.map(parseMateDailyScheduleSlot),
558
+ };
559
+ }
560
+ function readMateDailySchedule() {
561
+ const raw = fs.readFileSync(MATE_DAILY_SCHEDULE_PATH, "utf8");
562
+ return parseMateDailySchedule(JSON.parse(raw));
563
+ }
564
+ function createMateDailyScheduleSyncTool(service) {
565
+ return {
566
+ name: "kichi_sync_mate_daily_schedule",
567
+ label: "kichi_sync_mate_daily_schedule",
568
+ description: "Read today's mate daily schedule from the canonical Kichi World file and sync it to Kichi server.",
569
+ parameters: { type: "object", properties: {} },
570
+ execute: async (_toolCallId, _params) => {
571
+ try {
572
+ const schedule = readMateDailySchedule();
573
+ service.syncMateDailySchedule(schedule);
574
+ return jsonResult({
575
+ success: true,
576
+ date: schedule.date,
577
+ slotCount: schedule.slots.length,
578
+ });
579
+ }
580
+ catch (error) {
581
+ return jsonResult({
582
+ success: false,
583
+ error: `Failed to sync mate daily schedule from ${MATE_DAILY_SCHEDULE_PATH}: ${error instanceof Error ? error.message : String(error)}`,
584
+ });
585
+ }
586
+ },
587
+ };
588
+ }
493
589
  function isNonNegativeInteger(value) {
494
590
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
495
591
  }
@@ -864,7 +960,6 @@ function buildKichiActionDescription(service) {
864
960
  return lines.join("\n");
865
961
  }
866
962
  function buildKichiIdlePlanDescription() {
867
- const actions = loadStaticConfig().actions;
868
963
  return [
869
964
  "Send a complete heartbeat idle plan for the avatar.",
870
965
  "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 +971,36 @@ function buildKichiIdlePlanDescription() {
876
971
  "5. Set each stage avatarStatus to the avatar status for that stage: Idle, Busy, Activities, or Break.",
877
972
  "6. Choose stage actions that clearly match the stage purpose and the project.",
878
973
  "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.",
974
+ "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
975
  "Treat the avatar's idle plan as what your resident body is doing in Kichi World.",
881
976
  "Use your memory to recall what you did in past heartbeats and to stay consistent with your established personality and interests.",
882
977
  "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(", ")}`,
978
+ "Choose action names from the per-pose action lists in the kichi_action tool description (stand/sit/lay/floor).",
887
979
  ].join("\n");
888
980
  }
889
- function buildKichiPrompt() {
981
+ function buildKichiPrompt(isOfficialOpenClaw) {
982
+ const selfieRule = isOfficialOpenClaw
983
+ ? [
984
+ "",
985
+ "SELFIE / AVATAR IMAGE RULE:",
986
+ "Use this workflow for every request for a selfie, portrait, or other image of your appearance, including fictional and in-character scenes:",
987
+ "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.",
988
+ "2. Build the image prompt from this template:",
989
+ '"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]"',
990
+ "Replace `[image description]` with a concise visual description of the user's request. All appearance details come from the reference image.",
991
+ "3. Call `image_generate` directly with this input mapping:",
992
+ "- `image`: the exact `Avatar` value from step 1",
993
+ "- `prompt`: the completed prompt from step 2",
994
+ "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.",
995
+ "If the user reports an appearance mismatch, respond in character and use the same `IDENTITY.md` avatar reference for the next image.",
996
+ "If generation fails, tell the user that the requested image could not be created and stop.",
997
+ ]
998
+ : [];
890
999
  return [
891
1000
  "Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
892
1001
  "",
893
1002
  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.",
1003
+ ...selfieRule,
897
1004
  "",
898
1005
  "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
1006
  "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 +1055,21 @@ const plugin = {
948
1055
  return;
949
1056
  }
950
1057
  const now = Date.now();
1058
+ for (const [key, at] of botMessageCooldowns) {
1059
+ if (now - at >= BOT_MESSAGE_COOLDOWN_MS) {
1060
+ botMessageCooldowns.delete(key);
1061
+ }
1062
+ }
951
1063
  const cooldownKey = `${service.getAgentId()}:${msg.from}`;
952
1064
  const lastReply = botMessageCooldowns.get(cooldownKey) ?? 0;
953
1065
  if (now - lastReply < BOT_MESSAGE_COOLDOWN_MS)
954
1066
  return;
955
1067
  botMessageCooldowns.set(cooldownKey, now);
1068
+ const releaseCooldown = () => {
1069
+ if (botMessageCooldowns.get(cooldownKey) === now) {
1070
+ botMessageCooldowns.delete(cooldownKey);
1071
+ }
1072
+ };
956
1073
  const sessionKey = `agent:${service.getAgentId()}:bot_message`;
957
1074
  const history = [
958
1075
  ...(msg.history ?? []),
@@ -980,6 +1097,7 @@ const plugin = {
980
1097
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send or history record failed: ${sendErr}`);
981
1098
  });
982
1099
  }).catch((err) => {
1100
+ releaseCooldown();
983
1101
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
984
1102
  });
985
1103
  });
@@ -997,6 +1115,18 @@ const plugin = {
997
1115
  }
998
1116
  },
999
1117
  });
1118
+ api.registerTool((ctx) => {
1119
+ const locator = resolveToolLocator(ctx);
1120
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1121
+ if (!agentId) {
1122
+ return null;
1123
+ }
1124
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1125
+ if (!service.isOfficialOpenClawSource()) {
1126
+ return null;
1127
+ }
1128
+ return createMateDailyScheduleSyncTool(service);
1129
+ }, { name: "kichi_sync_mate_daily_schedule" });
1000
1130
  api.registerTool((ctx) => ({
1001
1131
  name: "kichi_join",
1002
1132
  label: "kichi_join",
@@ -1330,8 +1460,10 @@ const plugin = {
1330
1460
  });
1331
1461
  }
1332
1462
  }
1333
- catch {
1334
- // Server not updated or timeoutfall through to normal success
1463
+ catch (error) {
1464
+ // Server not updated or ack timed out the status was almost certainly
1465
+ // sent, so report success rather than derailing the agent's workflow.
1466
+ api.logger.debug(`[kichi:${service.getAgentId()}] verified status ack unavailable, reporting unverified success: ${error}`);
1335
1467
  }
1336
1468
  }
1337
1469
  else {
@@ -1559,7 +1691,7 @@ const plugin = {
1559
1691
  },
1560
1692
  kichiSeconds: {
1561
1693
  type: "number",
1562
- description: "Pomodoro kichi duration in seconds",
1694
+ description: "Pomodoro focus-phase duration in seconds (the concentrated work session, named kichi in this product)",
1563
1695
  },
1564
1696
  shortBreakSeconds: {
1565
1697
  type: "number",
@@ -1642,7 +1774,7 @@ const plugin = {
1642
1774
  api.registerTool((ctx) => ({
1643
1775
  name: "kichi_query_status",
1644
1776
  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.",
1777
+ 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
1778
  parameters: {
1647
1779
  type: "object",
1648
1780
  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
  }
@@ -162,6 +169,22 @@ export class KichiForwarderService {
162
169
  this.ws.send(JSON.stringify(outboundPayload));
163
170
  return true;
164
171
  }
172
+ syncMateDailySchedule(schedule) {
173
+ const identity = this.requireIdentity();
174
+ if (!identity) {
175
+ throw new Error("Missing Kichi identity");
176
+ }
177
+ if (this.ws?.readyState !== WebSocket.OPEN) {
178
+ throw new Error("Kichi websocket is not connected");
179
+ }
180
+ const payload = {
181
+ type: "kichi_sync_mate_daily_schedule",
182
+ avatarId: identity.avatarId,
183
+ authKey: identity.authKey,
184
+ schedule,
185
+ };
186
+ this.ws.send(JSON.stringify(payload));
187
+ }
165
188
  sendClock(action, clock, requestId) {
166
189
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN)
167
190
  return false;
@@ -344,6 +367,9 @@ export class KichiForwarderService {
344
367
  }
345
368
  return source.trim();
346
369
  }
370
+ isOfficialOpenClawSource() {
371
+ return this.readConfiguredJoinSource() === OFFICIAL_OPENCLAW_JOIN_SOURCE;
372
+ }
347
373
  getStatePath() {
348
374
  return path.join(this.options.runtimeDir, "state.json");
349
375
  }
@@ -445,18 +471,28 @@ export class KichiForwarderService {
445
471
  return { success: false, error: "Failed or not connected" };
446
472
  }
447
473
  return new Promise((resolve) => {
474
+ let timer = null;
475
+ let settled = false;
476
+ const finish = (result) => {
477
+ if (settled)
478
+ return;
479
+ settled = true;
480
+ if (timer)
481
+ clearTimeout(timer);
482
+ this.ws?.off("message", handler);
483
+ resolve(result);
484
+ };
448
485
  const handler = (data) => {
449
486
  try {
450
487
  const msg = JSON.parse(data.toString());
451
488
  if (msg.type === "leave_ack") {
452
- this.ws?.off("message", handler);
453
489
  const leaveAck = msg;
454
490
  if (leaveAck.success === false) {
455
- resolve(this.buildAckFailure(leaveAck, "Leave failed"));
491
+ finish(this.buildAckFailure(leaveAck, "Leave failed"));
456
492
  return;
457
493
  }
458
494
  this.clearAuthKey();
459
- resolve({ success: true });
495
+ finish({ success: true });
460
496
  }
461
497
  }
462
498
  catch (e) {
@@ -465,9 +501,8 @@ export class KichiForwarderService {
465
501
  };
466
502
  this.ws.on("message", handler);
467
503
  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" });
504
+ timer = setTimeout(() => {
505
+ finish({ success: false, error: "Timed out waiting for leave_ack" });
471
506
  }, 10000);
472
507
  });
473
508
  }
@@ -732,9 +767,11 @@ export class KichiForwarderService {
732
767
  return `${protocol}://${this.host}${port}/ws/openclaw`;
733
768
  }
734
769
  isPlainIpHost(host) {
770
+ // Bare IPv6 must contain a colon, otherwise hex-only hostnames like
771
+ // "beef" would be misclassified as local addresses and downgraded to ws://.
735
772
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
736
773
  || /^\[[0-9a-f:]+\]$/i.test(host)
737
- || /^[0-9a-f:]+$/i.test(host);
774
+ || (host.includes(":") && /^[0-9a-f:]+$/i.test(host));
738
775
  }
739
776
  persistCurrentHost(host, environment) {
740
777
  const previousState = this.readStateFile();
@@ -778,45 +815,67 @@ export class KichiForwarderService {
778
815
  fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
779
816
  fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
780
817
  }
818
+ // Corrupt persistent files must never break hooks or message handling:
819
+ // log, move the bad file aside for later inspection, and treat it as missing
820
+ // so the next write rebuilds it.
821
+ quarantineCorruptFile(filePath, reason) {
822
+ this.log("warn", `${reason}; treating ${filePath} as missing`);
823
+ try {
824
+ fs.renameSync(filePath, `${filePath}.corrupt`);
825
+ }
826
+ catch {
827
+ // Best effort — leave the file in place if the rename fails.
828
+ }
829
+ }
830
+ readJsonFileOrQuarantine(filePath) {
831
+ if (!fs.existsSync(filePath)) {
832
+ return null;
833
+ }
834
+ try {
835
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
836
+ }
837
+ catch (e) {
838
+ this.quarantineCorruptFile(filePath, `failed to read or parse ${filePath}: ${e}`);
839
+ return null;
840
+ }
841
+ }
781
842
  readStateFile() {
782
843
  const statePath = this.getStatePath();
783
- if (!fs.existsSync(statePath)) {
844
+ const data = this.readJsonFileOrQuarantine(statePath);
845
+ if (data === null) {
784
846
  return null;
785
847
  }
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}`);
848
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
849
+ this.quarantineCorruptFile(statePath, `invalid state payload in ${statePath}`);
850
+ return null;
789
851
  }
790
852
  return data;
791
853
  }
792
854
  readSmsStateFile() {
793
855
  const smsStatePath = this.getSmsStatePath();
794
- if (!fs.existsSync(smsStatePath)) {
856
+ const data = this.readJsonFileOrQuarantine(smsStatePath);
857
+ if (data === null) {
795
858
  return null;
796
859
  }
797
- const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8"));
798
860
  if (!data || typeof data !== "object" || Array.isArray(data)) {
799
- throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
861
+ this.quarantineCorruptFile(smsStatePath, `invalid SMS state payload in ${smsStatePath}`);
862
+ return null;
800
863
  }
801
864
  return data;
802
865
  }
803
866
  readBotMessageTranscriptStore() {
804
867
  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}`);
868
+ const emptyStore = { version: 1, entries: [] };
869
+ const data = this.readJsonFileOrQuarantine(historyPath);
870
+ if (data === null) {
871
+ return emptyStore;
811
872
  }
812
873
  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
- }
874
+ if (!data || typeof data !== "object" || Array.isArray(data)
875
+ || store.version !== 1 || !Array.isArray(store.entries)
876
+ || !store.entries.every((entry) => this.isValidBotMessageTranscriptEntry(entry))) {
877
+ this.quarantineCorruptFile(historyPath, `invalid bot message history payload in ${historyPath}`);
878
+ return emptyStore;
820
879
  }
821
880
  return {
822
881
  version: 1,
package/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import type {
4
6
  OpenClawPluginApi,
@@ -21,10 +23,21 @@ import type {
21
23
  KichiEnvironment,
22
24
  KichiEnvironmentsConfig,
23
25
  KichiStaticConfig,
26
+ MateDailySchedule,
27
+ MateDailySchedulePlace,
28
+ MateDailyScheduleSlot,
24
29
  PomodoroPhase,
25
30
  PoseType,
26
31
  } from "./src/types.js";
27
32
  const BUNDLED_STATIC_CONFIG_PATH = new URL("./config/kichi-config.json", import.meta.url);
33
+ const MATE_DAILY_SCHEDULE_PATH = path.join(
34
+ os.homedir(),
35
+ ".openclaw",
36
+ "kichi-world",
37
+ "agents",
38
+ "main",
39
+ "daily-schedule.json",
40
+ );
28
41
 
29
42
  function jsonResult(payload: unknown): { content: { type: "text"; text: string }[]; details: unknown } {
30
43
  return { content: [{ type: "text", text: JSON.stringify(payload) }], details: payload };
@@ -594,7 +607,7 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
594
607
  return;
595
608
  }
596
609
  return {
597
- prependContext: buildKichiPrompt(),
610
+ prependContext: buildKichiPrompt(service.isOfficialOpenClawSource()),
598
611
  };
599
612
  });
600
613
 
@@ -636,6 +649,106 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
636
649
  return !!value && typeof value === "object" && !Array.isArray(value);
637
650
  }
638
651
 
652
+ function parseMateDailySchedulePlace(value: unknown, slotIndex: number): MateDailySchedulePlace {
653
+ if (!isPlainObject(value)) {
654
+ throw new Error(`slots[${slotIndex}].place must be an object`);
655
+ }
656
+
657
+ const type = value.type;
658
+ if (type === "home" || type === "kichi_room") {
659
+ return { type };
660
+ }
661
+ if (type === "real_world") {
662
+ if (typeof value.name !== "string" || !value.name.trim()) {
663
+ throw new Error(`slots[${slotIndex}].place.name must be a non-empty string for real_world`);
664
+ }
665
+ return { type, name: value.name };
666
+ }
667
+ throw new Error(`slots[${slotIndex}].place.type must be one of: home, kichi_room, real_world`);
668
+ }
669
+
670
+ function parseMateDailyScheduleSlot(value: unknown, index: number): MateDailyScheduleSlot {
671
+ if (!isPlainObject(value)) {
672
+ throw new Error(`slots[${index}] must be an object`);
673
+ }
674
+ const hour = value.h;
675
+ if (typeof hour !== "number" || !Number.isInteger(hour) || hour < 0 || hour > 23) {
676
+ throw new Error(`slots[${index}].h must be an integer between 0 and 23`);
677
+ }
678
+ if (typeof value.act !== "string") {
679
+ throw new Error(`slots[${index}].act must be a string`);
680
+ }
681
+ if (typeof value.mood !== "string") {
682
+ throw new Error(`slots[${index}].mood must be a string`);
683
+ }
684
+ if (typeof value.sleep !== "boolean") {
685
+ throw new Error(`slots[${index}].sleep must be a boolean`);
686
+ }
687
+
688
+ return {
689
+ h: hour,
690
+ place: parseMateDailySchedulePlace(value.place, index),
691
+ act: value.act,
692
+ mood: value.mood,
693
+ sleep: value.sleep,
694
+ };
695
+ }
696
+
697
+ function parseMateDailySchedule(value: unknown): MateDailySchedule {
698
+ if (!isPlainObject(value)) {
699
+ throw new Error("daily-schedule.json must contain a JSON object");
700
+ }
701
+ if (typeof value.date !== "string" || !value.date.trim()) {
702
+ throw new Error("date must be a non-empty string");
703
+ }
704
+ if (typeof value.timezone !== "string" || !value.timezone.trim()) {
705
+ throw new Error("timezone must be a non-empty string");
706
+ }
707
+ if (!Array.isArray(value.activeArcs) || !value.activeArcs.every((item) => typeof item === "string")) {
708
+ throw new Error("activeArcs must be an array of strings");
709
+ }
710
+ if (!Array.isArray(value.slots)) {
711
+ throw new Error("slots must be an array");
712
+ }
713
+
714
+ return {
715
+ date: value.date,
716
+ timezone: value.timezone,
717
+ activeArcs: value.activeArcs,
718
+ slots: value.slots.map(parseMateDailyScheduleSlot),
719
+ };
720
+ }
721
+
722
+ function readMateDailySchedule(): MateDailySchedule {
723
+ const raw = fs.readFileSync(MATE_DAILY_SCHEDULE_PATH, "utf8");
724
+ return parseMateDailySchedule(JSON.parse(raw) as unknown);
725
+ }
726
+
727
+ function createMateDailyScheduleSyncTool(service: KichiForwarderService) {
728
+ return {
729
+ name: "kichi_sync_mate_daily_schedule" as const,
730
+ label: "kichi_sync_mate_daily_schedule",
731
+ description: "Read today's mate daily schedule from the canonical Kichi World file and sync it to Kichi server.",
732
+ parameters: { type: "object" as const, properties: {} },
733
+ execute: async (_toolCallId: string, _params: unknown) => {
734
+ try {
735
+ const schedule = readMateDailySchedule();
736
+ service.syncMateDailySchedule(schedule);
737
+ return jsonResult({
738
+ success: true,
739
+ date: schedule.date,
740
+ slotCount: schedule.slots.length,
741
+ });
742
+ } catch (error) {
743
+ return jsonResult({
744
+ success: false,
745
+ error: `Failed to sync mate daily schedule from ${MATE_DAILY_SCHEDULE_PATH}: ${error instanceof Error ? error.message : String(error)}`,
746
+ });
747
+ }
748
+ },
749
+ };
750
+ }
751
+
639
752
  function isNonNegativeInteger(value: unknown): value is number {
640
753
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
641
754
  }
@@ -1076,7 +1189,6 @@ function buildKichiActionDescription(service?: KichiForwarderService): string {
1076
1189
  }
1077
1190
 
1078
1191
  function buildKichiIdlePlanDescription(): string {
1079
- const actions = loadStaticConfig().actions;
1080
1192
  return [
1081
1193
  "Send a complete heartbeat idle plan for the avatar.",
1082
1194
  "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 +1200,38 @@ function buildKichiIdlePlanDescription(): string {
1088
1200
  "5. Set each stage avatarStatus to the avatar status for that stage: Idle, Busy, Activities, or Break.",
1089
1201
  "6. Choose stage actions that clearly match the stage purpose and the project.",
1090
1202
  "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.",
1203
+ "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
1204
  "Treat the avatar's idle plan as what your resident body is doing in Kichi World.",
1093
1205
  "Use your memory to recall what you did in past heartbeats and to stay consistent with your established personality and interests.",
1094
1206
  "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(", ")}`,
1207
+ "Choose action names from the per-pose action lists in the kichi_action tool description (stand/sit/lay/floor).",
1099
1208
  ].join("\n");
1100
1209
  }
1101
1210
 
1102
- function buildKichiPrompt(): string {
1211
+ function buildKichiPrompt(isOfficialOpenClaw: boolean): string {
1212
+ const selfieRule = isOfficialOpenClaw
1213
+ ? [
1214
+ "",
1215
+ "SELFIE / AVATAR IMAGE RULE:",
1216
+ "Use this workflow for every request for a selfie, portrait, or other image of your appearance, including fictional and in-character scenes:",
1217
+ "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.",
1218
+ "2. Build the image prompt from this template:",
1219
+ '"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]"',
1220
+ "Replace `[image description]` with a concise visual description of the user's request. All appearance details come from the reference image.",
1221
+ "3. Call `image_generate` directly with this input mapping:",
1222
+ "- `image`: the exact `Avatar` value from step 1",
1223
+ "- `prompt`: the completed prompt from step 2",
1224
+ "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.",
1225
+ "If the user reports an appearance mismatch, respond in character and use the same `IDENTITY.md` avatar reference for the next image.",
1226
+ "If generation fails, tell the user that the requested image could not be created and stop.",
1227
+ ]
1228
+ : [];
1229
+
1103
1230
  return [
1104
1231
  "Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
1105
1232
  "",
1106
1233
  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.",
1234
+ ...selfieRule,
1110
1235
  "",
1111
1236
  "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
1237
  "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 +1299,20 @@ const plugin = {
1174
1299
  return;
1175
1300
  }
1176
1301
  const now = Date.now();
1302
+ for (const [key, at] of botMessageCooldowns) {
1303
+ if (now - at >= BOT_MESSAGE_COOLDOWN_MS) {
1304
+ botMessageCooldowns.delete(key);
1305
+ }
1306
+ }
1177
1307
  const cooldownKey = `${service.getAgentId()}:${msg.from}`;
1178
1308
  const lastReply = botMessageCooldowns.get(cooldownKey) ?? 0;
1179
1309
  if (now - lastReply < BOT_MESSAGE_COOLDOWN_MS) return;
1180
1310
  botMessageCooldowns.set(cooldownKey, now);
1311
+ const releaseCooldown = () => {
1312
+ if (botMessageCooldowns.get(cooldownKey) === now) {
1313
+ botMessageCooldowns.delete(cooldownKey);
1314
+ }
1315
+ };
1181
1316
  const sessionKey = `agent:${service.getAgentId()}:bot_message`;
1182
1317
  const history: BotMessageHistoryEntry[] = [
1183
1318
  ...(msg.history ?? []),
@@ -1205,6 +1340,7 @@ const plugin = {
1205
1340
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send or history record failed: ${sendErr}`);
1206
1341
  });
1207
1342
  }).catch((err) => {
1343
+ releaseCooldown();
1208
1344
  api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
1209
1345
  });
1210
1346
  });
@@ -1224,6 +1360,19 @@ const plugin = {
1224
1360
  },
1225
1361
  });
1226
1362
 
1363
+ api.registerTool((ctx) => {
1364
+ const locator = resolveToolLocator(ctx);
1365
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1366
+ if (!agentId) {
1367
+ return null;
1368
+ }
1369
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1370
+ if (!service.isOfficialOpenClawSource()) {
1371
+ return null;
1372
+ }
1373
+ return createMateDailyScheduleSyncTool(service);
1374
+ }, { name: "kichi_sync_mate_daily_schedule" });
1375
+
1227
1376
  api.registerTool((ctx) => ({
1228
1377
  name: "kichi_join",
1229
1378
  label: "kichi_join",
@@ -1598,8 +1747,12 @@ const plugin = {
1598
1747
  warning: ack.warning,
1599
1748
  });
1600
1749
  }
1601
- } catch {
1602
- // Server not updated or timeoutfall through to normal success
1750
+ } catch (error) {
1751
+ // Server not updated or ack timed out the status was almost certainly
1752
+ // sent, so report success rather than derailing the agent's workflow.
1753
+ api.logger.debug(
1754
+ `[kichi:${service.getAgentId()}] verified status ack unavailable, reporting unverified success: ${error}`,
1755
+ );
1603
1756
  }
1604
1757
  } else {
1605
1758
  sendStatusUpdate(service, {
@@ -1839,7 +1992,7 @@ const plugin = {
1839
1992
  },
1840
1993
  kichiSeconds: {
1841
1994
  type: "number",
1842
- description: "Pomodoro kichi duration in seconds",
1995
+ description: "Pomodoro focus-phase duration in seconds (the concentrated work session, named kichi in this product)",
1843
1996
  },
1844
1997
  shortBreakSeconds: {
1845
1998
  type: "number",
@@ -1932,7 +2085,7 @@ const plugin = {
1932
2085
  name: "kichi_query_status",
1933
2086
  label: "kichi_query_status",
1934
2087
  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.",
2088
+ "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
2089
  parameters: {
1937
2090
  type: "object",
1938
2091
  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.30",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/kichi-forwarder"],
8
8
  "contracts": {
@@ -15,6 +15,7 @@
15
15
  "kichi_action",
16
16
  "kichi_glance",
17
17
  "kichi_idle_plan",
18
+ "kichi_sync_mate_daily_schedule",
18
19
  "kichi_clock",
19
20
  "kichi_query_status",
20
21
  "kichi_music_album_create",
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.30",
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
@@ -30,17 +30,20 @@ import type {
30
30
  KichiIdentity,
31
31
  KichiState,
32
32
  LeaveAckPayload,
33
+ MateDailySchedule,
33
34
  PoseType,
34
35
  QueryStatusPayload,
35
36
  QueryStatusResultPayload,
36
37
  StatusAckPayload,
37
38
  StatusPayload,
39
+ SyncMateDailySchedulePayload,
38
40
  } from "./types.js";
39
41
 
40
42
  const MAX_NOTEBOARD_TEXT_LENGTH = 200;
41
43
  const DEFAULT_LLM_RUNTIME_ENABLED = true;
42
44
  const DEFAULT_GLANCE_DURATION_SECONDS = 1.8;
43
45
  const JOIN_SOURCE_FILE_NAME = "join-source.json";
46
+ const OFFICIAL_OPENCLAW_JOIN_SOURCE = "kichiclaw";
44
47
  const SMS_STATE_FILE_NAME = "sms-state.json";
45
48
  const BOT_MESSAGE_HISTORY_FILE_NAME = "bot-message-history.json";
46
49
  const MAX_BOT_MESSAGE_HISTORY_ENTRIES = 30;
@@ -179,7 +182,12 @@ export class KichiForwarderService {
179
182
  this.saveIdentity();
180
183
  this.joinResolve = resolve;
181
184
  const payload: JoinPayload = { type: "join", avatarId, botName, bio, tags, source };
182
- const sendJoin = () => this.ws?.send(JSON.stringify(payload));
185
+ const sendJoin = () => {
186
+ // Skip if this join has timed out or been superseded by a newer join —
187
+ // a stale "open" listener must not send an outdated join payload.
188
+ if (this.joinResolve !== resolve) return;
189
+ this.ws?.send(JSON.stringify(payload));
190
+ };
183
191
  if (this.ws?.readyState === WebSocket.OPEN) {
184
192
  sendJoin();
185
193
  } else {
@@ -275,6 +283,24 @@ export class KichiForwarderService {
275
283
  return true;
276
284
  }
277
285
 
286
+ syncMateDailySchedule(schedule: MateDailySchedule): void {
287
+ const identity = this.requireIdentity();
288
+ if (!identity) {
289
+ throw new Error("Missing Kichi identity");
290
+ }
291
+ if (this.ws?.readyState !== WebSocket.OPEN) {
292
+ throw new Error("Kichi websocket is not connected");
293
+ }
294
+
295
+ const payload: SyncMateDailySchedulePayload = {
296
+ type: "kichi_sync_mate_daily_schedule",
297
+ avatarId: identity.avatarId,
298
+ authKey: identity.authKey,
299
+ schedule,
300
+ };
301
+ this.ws.send(JSON.stringify(payload));
302
+ }
303
+
278
304
  sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
279
305
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
280
306
  if (action === "set" && !clock) return false;
@@ -492,6 +518,10 @@ export class KichiForwarderService {
492
518
  return source.trim();
493
519
  }
494
520
 
521
+ isOfficialOpenClawSource(): boolean {
522
+ return this.readConfiguredJoinSource() === OFFICIAL_OPENCLAW_JOIN_SOURCE;
523
+ }
524
+
495
525
  getStatePath(): string {
496
526
  return path.join(this.options.runtimeDir, "state.json");
497
527
  }
@@ -606,18 +636,26 @@ export class KichiForwarderService {
606
636
  }
607
637
 
608
638
  return new Promise((resolve) => {
639
+ let timer: NodeJS.Timeout | null = null;
640
+ let settled = false;
641
+ const finish = (result: LeaveResult) => {
642
+ if (settled) return;
643
+ settled = true;
644
+ if (timer) clearTimeout(timer);
645
+ this.ws?.off("message", handler);
646
+ resolve(result);
647
+ };
609
648
  const handler = (data: WebSocket.Data) => {
610
649
  try {
611
650
  const msg = JSON.parse(data.toString());
612
651
  if (msg.type === "leave_ack") {
613
- this.ws?.off("message", handler);
614
652
  const leaveAck = msg as LeaveAckPayload;
615
653
  if (leaveAck.success === false) {
616
- resolve(this.buildAckFailure(leaveAck, "Leave failed"));
654
+ finish(this.buildAckFailure(leaveAck, "Leave failed"));
617
655
  return;
618
656
  }
619
657
  this.clearAuthKey();
620
- resolve({ success: true });
658
+ finish({ success: true });
621
659
  }
622
660
  } catch (e) {
623
661
  this.log("warn", `failed to parse leave response: ${e}`);
@@ -627,9 +665,8 @@ export class KichiForwarderService {
627
665
  this.ws!.send(
628
666
  JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
629
667
  );
630
- setTimeout(() => {
631
- this.ws?.off("message", handler);
632
- resolve({ success: false, error: "Timed out waiting for leave_ack" });
668
+ timer = setTimeout(() => {
669
+ finish({ success: false, error: "Timed out waiting for leave_ack" });
633
670
  }, 10000);
634
671
  });
635
672
  }
@@ -922,9 +959,11 @@ export class KichiForwarderService {
922
959
  }
923
960
 
924
961
  private isPlainIpHost(host: string): boolean {
962
+ // Bare IPv6 must contain a colon, otherwise hex-only hostnames like
963
+ // "beef" would be misclassified as local addresses and downgraded to ws://.
925
964
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
926
965
  || /^\[[0-9a-f:]+\]$/i.test(host)
927
- || /^[0-9a-f:]+$/i.test(host);
966
+ || (host.includes(":") && /^[0-9a-f:]+$/i.test(host));
928
967
  }
929
968
 
930
969
  private persistCurrentHost(host: string, environment?: KichiEnvironment): void {
@@ -972,47 +1011,69 @@ export class KichiForwarderService {
972
1011
  fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
973
1012
  }
974
1013
 
1014
+ // Corrupt persistent files must never break hooks or message handling:
1015
+ // log, move the bad file aside for later inspection, and treat it as missing
1016
+ // so the next write rebuilds it.
1017
+ private quarantineCorruptFile(filePath: string, reason: string): void {
1018
+ this.log("warn", `${reason}; treating ${filePath} as missing`);
1019
+ try {
1020
+ fs.renameSync(filePath, `${filePath}.corrupt`);
1021
+ } catch {
1022
+ // Best effort — leave the file in place if the rename fails.
1023
+ }
1024
+ }
1025
+
1026
+ private readJsonFileOrQuarantine(filePath: string): unknown | null {
1027
+ if (!fs.existsSync(filePath)) {
1028
+ return null;
1029
+ }
1030
+ try {
1031
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
1032
+ } catch (e) {
1033
+ this.quarantineCorruptFile(filePath, `failed to read or parse ${filePath}: ${e}`);
1034
+ return null;
1035
+ }
1036
+ }
1037
+
975
1038
  private readStateFile(): Partial<KichiState> | null {
976
1039
  const statePath = this.getStatePath();
977
- if (!fs.existsSync(statePath)) {
1040
+ const data = this.readJsonFileOrQuarantine(statePath);
1041
+ if (data === null) {
978
1042
  return null;
979
1043
  }
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}`);
1044
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
1045
+ this.quarantineCorruptFile(statePath, `invalid state payload in ${statePath}`);
1046
+ return null;
983
1047
  }
984
1048
  return data as Partial<KichiState>;
985
1049
  }
986
1050
 
987
1051
  private readSmsStateFile(): Partial<SmsState> | null {
988
1052
  const smsStatePath = this.getSmsStatePath();
989
- if (!fs.existsSync(smsStatePath)) {
1053
+ const data = this.readJsonFileOrQuarantine(smsStatePath);
1054
+ if (data === null) {
990
1055
  return null;
991
1056
  }
992
- const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8")) as unknown;
993
1057
  if (!data || typeof data !== "object" || Array.isArray(data)) {
994
- throw new Error(`Invalid SMS state payload in ${smsStatePath}`);
1058
+ this.quarantineCorruptFile(smsStatePath, `invalid SMS state payload in ${smsStatePath}`);
1059
+ return null;
995
1060
  }
996
1061
  return data as Partial<SmsState>;
997
1062
  }
998
1063
 
999
1064
  private readBotMessageTranscriptStore(): BotMessageTranscriptStore {
1000
1065
  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}`);
1066
+ const emptyStore: BotMessageTranscriptStore = { version: 1, entries: [] };
1067
+ const data = this.readJsonFileOrQuarantine(historyPath);
1068
+ if (data === null) {
1069
+ return emptyStore;
1007
1070
  }
1008
1071
  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
- }
1072
+ if (!data || typeof data !== "object" || Array.isArray(data)
1073
+ || store.version !== 1 || !Array.isArray(store.entries)
1074
+ || !store.entries.every((entry) => this.isValidBotMessageTranscriptEntry(entry))) {
1075
+ this.quarantineCorruptFile(historyPath, `invalid bot message history payload in ${historyPath}`);
1076
+ return emptyStore;
1016
1077
  }
1017
1078
  return {
1018
1079
  version: 1,
package/src/types.ts CHANGED
@@ -194,6 +194,33 @@ export type IdlePlanPayload = IdlePlanContent & {
194
194
  authKey: string;
195
195
  };
196
196
 
197
+ export type MateDailySchedulePlace =
198
+ | { type: "home" }
199
+ | { type: "kichi_room" }
200
+ | { type: "real_world"; name: string };
201
+
202
+ export type MateDailyScheduleSlot = {
203
+ h: number;
204
+ place: MateDailySchedulePlace;
205
+ act: string;
206
+ mood: string;
207
+ sleep: boolean;
208
+ };
209
+
210
+ export type MateDailySchedule = {
211
+ date: string;
212
+ timezone: string;
213
+ activeArcs: string[];
214
+ slots: MateDailyScheduleSlot[];
215
+ };
216
+
217
+ export type SyncMateDailySchedulePayload = {
218
+ type: "kichi_sync_mate_daily_schedule";
219
+ avatarId: string;
220
+ authKey: string;
221
+ schedule: MateDailySchedule;
222
+ };
223
+
197
224
  export type ClockAction = "set" | "stop";
198
225
 
199
226
  export type ClockMode = "pomodoro" | "countDown" | "countUp";