@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 +39 -15
- package/dist/src/service.js +71 -28
- package/index.ts +42 -15
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +4 -19
- package/skills/kichi-forwarder/references/heartbeat.md +3 -3
- package/skills/kichi-forwarder/references/install.md +1 -1
- package/src/service.ts +69 -28
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
package/dist/src/service.js
CHANGED
|
@@ -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 = () =>
|
|
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
|
-
|
|
475
|
+
finish(this.buildAckFailure(leaveAck, "Leave failed"));
|
|
456
476
|
return;
|
|
457
477
|
}
|
|
458
478
|
this.clearAuthKey();
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
828
|
+
const data = this.readJsonFileOrQuarantine(statePath);
|
|
829
|
+
if (data === null) {
|
|
784
830
|
return null;
|
|
785
831
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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 (
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
168
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = () =>
|
|
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
|
-
|
|
634
|
+
finish(this.buildAckFailure(leaveAck, "Leave failed"));
|
|
617
635
|
return;
|
|
618
636
|
}
|
|
619
637
|
this.clearAuthKey();
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1020
|
+
const data = this.readJsonFileOrQuarantine(statePath);
|
|
1021
|
+
if (data === null) {
|
|
978
1022
|
return null;
|
|
979
1023
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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 (
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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,
|