@yahaha-studio/kichi-forwarder 0.1.2-beta.27 → 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 +77 -29
- 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 +75 -29
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
|
}
|
|
@@ -222,7 +229,12 @@ export class KichiForwarderService {
|
|
|
222
229
|
authKey: identity.authKey,
|
|
223
230
|
};
|
|
224
231
|
const result = await this.sendRequest(payload, "query_status_result");
|
|
225
|
-
|
|
232
|
+
// Only mark the owner as active when they are actually present in Kichi.
|
|
233
|
+
// A plain status query (e.g. the hourly recall cron) must NOT refresh
|
|
234
|
+
// lastActiveAt, otherwise the offline check can never age past the window.
|
|
235
|
+
if (result.ownerState != null) {
|
|
236
|
+
this.updateSmsLastActiveAt();
|
|
237
|
+
}
|
|
226
238
|
if (result.RoomContext && typeof result.RoomContext === "object") {
|
|
227
239
|
this.cachedRoomContext = result.RoomContext;
|
|
228
240
|
}
|
|
@@ -339,6 +351,9 @@ export class KichiForwarderService {
|
|
|
339
351
|
}
|
|
340
352
|
return source.trim();
|
|
341
353
|
}
|
|
354
|
+
isOfficialOpenClawSource() {
|
|
355
|
+
return this.readConfiguredJoinSource() === OFFICIAL_OPENCLAW_JOIN_SOURCE;
|
|
356
|
+
}
|
|
342
357
|
getStatePath() {
|
|
343
358
|
return path.join(this.options.runtimeDir, "state.json");
|
|
344
359
|
}
|
|
@@ -440,18 +455,28 @@ export class KichiForwarderService {
|
|
|
440
455
|
return { success: false, error: "Failed or not connected" };
|
|
441
456
|
}
|
|
442
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
|
+
};
|
|
443
469
|
const handler = (data) => {
|
|
444
470
|
try {
|
|
445
471
|
const msg = JSON.parse(data.toString());
|
|
446
472
|
if (msg.type === "leave_ack") {
|
|
447
|
-
this.ws?.off("message", handler);
|
|
448
473
|
const leaveAck = msg;
|
|
449
474
|
if (leaveAck.success === false) {
|
|
450
|
-
|
|
475
|
+
finish(this.buildAckFailure(leaveAck, "Leave failed"));
|
|
451
476
|
return;
|
|
452
477
|
}
|
|
453
478
|
this.clearAuthKey();
|
|
454
|
-
|
|
479
|
+
finish({ success: true });
|
|
455
480
|
}
|
|
456
481
|
}
|
|
457
482
|
catch (e) {
|
|
@@ -460,9 +485,8 @@ export class KichiForwarderService {
|
|
|
460
485
|
};
|
|
461
486
|
this.ws.on("message", handler);
|
|
462
487
|
this.ws.send(JSON.stringify({ type: "leave", avatarId: this.identity.avatarId, authKey: this.identity.authKey }));
|
|
463
|
-
setTimeout(() => {
|
|
464
|
-
|
|
465
|
-
resolve({ success: false, error: "Timed out waiting for leave_ack" });
|
|
488
|
+
timer = setTimeout(() => {
|
|
489
|
+
finish({ success: false, error: "Timed out waiting for leave_ack" });
|
|
466
490
|
}, 10000);
|
|
467
491
|
});
|
|
468
492
|
}
|
|
@@ -727,9 +751,11 @@ export class KichiForwarderService {
|
|
|
727
751
|
return `${protocol}://${this.host}${port}/ws/openclaw`;
|
|
728
752
|
}
|
|
729
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://.
|
|
730
756
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
731
757
|
|| /^\[[0-9a-f:]+\]$/i.test(host)
|
|
732
|
-
|| /^[0-9a-f:]+$/i.test(host);
|
|
758
|
+
|| (host.includes(":") && /^[0-9a-f:]+$/i.test(host));
|
|
733
759
|
}
|
|
734
760
|
persistCurrentHost(host, environment) {
|
|
735
761
|
const previousState = this.readStateFile();
|
|
@@ -773,45 +799,67 @@ export class KichiForwarderService {
|
|
|
773
799
|
fs.mkdirSync(this.options.runtimeDir, { recursive: true, mode: 0o700 });
|
|
774
800
|
fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
|
|
775
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
|
+
}
|
|
776
826
|
readStateFile() {
|
|
777
827
|
const statePath = this.getStatePath();
|
|
778
|
-
|
|
828
|
+
const data = this.readJsonFileOrQuarantine(statePath);
|
|
829
|
+
if (data === null) {
|
|
779
830
|
return null;
|
|
780
831
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
832
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
833
|
+
this.quarantineCorruptFile(statePath, `invalid state payload in ${statePath}`);
|
|
834
|
+
return null;
|
|
784
835
|
}
|
|
785
836
|
return data;
|
|
786
837
|
}
|
|
787
838
|
readSmsStateFile() {
|
|
788
839
|
const smsStatePath = this.getSmsStatePath();
|
|
789
|
-
|
|
840
|
+
const data = this.readJsonFileOrQuarantine(smsStatePath);
|
|
841
|
+
if (data === null) {
|
|
790
842
|
return null;
|
|
791
843
|
}
|
|
792
|
-
const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8"));
|
|
793
844
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
794
|
-
|
|
845
|
+
this.quarantineCorruptFile(smsStatePath, `invalid SMS state payload in ${smsStatePath}`);
|
|
846
|
+
return null;
|
|
795
847
|
}
|
|
796
848
|
return data;
|
|
797
849
|
}
|
|
798
850
|
readBotMessageTranscriptStore() {
|
|
799
851
|
const historyPath = this.getBotMessageHistoryPath();
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
805
|
-
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;
|
|
806
856
|
}
|
|
807
857
|
const store = data;
|
|
808
|
-
if (
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
throw new Error(`Invalid bot message history entry in ${historyPath}`);
|
|
814
|
-
}
|
|
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;
|
|
815
863
|
}
|
|
816
864
|
return {
|
|
817
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 {
|
|
@@ -345,7 +351,12 @@ export class KichiForwarderService {
|
|
|
345
351
|
authKey: identity.authKey,
|
|
346
352
|
};
|
|
347
353
|
const result = await this.sendRequest<QueryStatusResultPayload>(payload, "query_status_result");
|
|
348
|
-
|
|
354
|
+
// Only mark the owner as active when they are actually present in Kichi.
|
|
355
|
+
// A plain status query (e.g. the hourly recall cron) must NOT refresh
|
|
356
|
+
// lastActiveAt, otherwise the offline check can never age past the window.
|
|
357
|
+
if (result.ownerState != null) {
|
|
358
|
+
this.updateSmsLastActiveAt();
|
|
359
|
+
}
|
|
349
360
|
if (result.RoomContext && typeof result.RoomContext === "object") {
|
|
350
361
|
this.cachedRoomContext = result.RoomContext as Record<string, unknown>;
|
|
351
362
|
}
|
|
@@ -487,6 +498,10 @@ export class KichiForwarderService {
|
|
|
487
498
|
return source.trim();
|
|
488
499
|
}
|
|
489
500
|
|
|
501
|
+
isOfficialOpenClawSource(): boolean {
|
|
502
|
+
return this.readConfiguredJoinSource() === OFFICIAL_OPENCLAW_JOIN_SOURCE;
|
|
503
|
+
}
|
|
504
|
+
|
|
490
505
|
getStatePath(): string {
|
|
491
506
|
return path.join(this.options.runtimeDir, "state.json");
|
|
492
507
|
}
|
|
@@ -601,18 +616,26 @@ export class KichiForwarderService {
|
|
|
601
616
|
}
|
|
602
617
|
|
|
603
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
|
+
};
|
|
604
628
|
const handler = (data: WebSocket.Data) => {
|
|
605
629
|
try {
|
|
606
630
|
const msg = JSON.parse(data.toString());
|
|
607
631
|
if (msg.type === "leave_ack") {
|
|
608
|
-
this.ws?.off("message", handler);
|
|
609
632
|
const leaveAck = msg as LeaveAckPayload;
|
|
610
633
|
if (leaveAck.success === false) {
|
|
611
|
-
|
|
634
|
+
finish(this.buildAckFailure(leaveAck, "Leave failed"));
|
|
612
635
|
return;
|
|
613
636
|
}
|
|
614
637
|
this.clearAuthKey();
|
|
615
|
-
|
|
638
|
+
finish({ success: true });
|
|
616
639
|
}
|
|
617
640
|
} catch (e) {
|
|
618
641
|
this.log("warn", `failed to parse leave response: ${e}`);
|
|
@@ -622,9 +645,8 @@ export class KichiForwarderService {
|
|
|
622
645
|
this.ws!.send(
|
|
623
646
|
JSON.stringify({ type: "leave", avatarId: this.identity!.avatarId, authKey: this.identity!.authKey }),
|
|
624
647
|
);
|
|
625
|
-
setTimeout(() => {
|
|
626
|
-
|
|
627
|
-
resolve({ success: false, error: "Timed out waiting for leave_ack" });
|
|
648
|
+
timer = setTimeout(() => {
|
|
649
|
+
finish({ success: false, error: "Timed out waiting for leave_ack" });
|
|
628
650
|
}, 10000);
|
|
629
651
|
});
|
|
630
652
|
}
|
|
@@ -917,9 +939,11 @@ export class KichiForwarderService {
|
|
|
917
939
|
}
|
|
918
940
|
|
|
919
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://.
|
|
920
944
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
921
945
|
|| /^\[[0-9a-f:]+\]$/i.test(host)
|
|
922
|
-
|| /^[0-9a-f:]+$/i.test(host);
|
|
946
|
+
|| (host.includes(":") && /^[0-9a-f:]+$/i.test(host));
|
|
923
947
|
}
|
|
924
948
|
|
|
925
949
|
private persistCurrentHost(host: string, environment?: KichiEnvironment): void {
|
|
@@ -967,47 +991,69 @@ export class KichiForwarderService {
|
|
|
967
991
|
fs.writeFileSync(this.getBotMessageHistoryPath(), JSON.stringify(nextStore, null, 2), { mode: 0o600 });
|
|
968
992
|
}
|
|
969
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
|
+
|
|
970
1018
|
private readStateFile(): Partial<KichiState> | null {
|
|
971
1019
|
const statePath = this.getStatePath();
|
|
972
|
-
|
|
1020
|
+
const data = this.readJsonFileOrQuarantine(statePath);
|
|
1021
|
+
if (data === null) {
|
|
973
1022
|
return null;
|
|
974
1023
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1024
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1025
|
+
this.quarantineCorruptFile(statePath, `invalid state payload in ${statePath}`);
|
|
1026
|
+
return null;
|
|
978
1027
|
}
|
|
979
1028
|
return data as Partial<KichiState>;
|
|
980
1029
|
}
|
|
981
1030
|
|
|
982
1031
|
private readSmsStateFile(): Partial<SmsState> | null {
|
|
983
1032
|
const smsStatePath = this.getSmsStatePath();
|
|
984
|
-
|
|
1033
|
+
const data = this.readJsonFileOrQuarantine(smsStatePath);
|
|
1034
|
+
if (data === null) {
|
|
985
1035
|
return null;
|
|
986
1036
|
}
|
|
987
|
-
const data = JSON.parse(fs.readFileSync(smsStatePath, "utf-8")) as unknown;
|
|
988
1037
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
989
|
-
|
|
1038
|
+
this.quarantineCorruptFile(smsStatePath, `invalid SMS state payload in ${smsStatePath}`);
|
|
1039
|
+
return null;
|
|
990
1040
|
}
|
|
991
1041
|
return data as Partial<SmsState>;
|
|
992
1042
|
}
|
|
993
1043
|
|
|
994
1044
|
private readBotMessageTranscriptStore(): BotMessageTranscriptStore {
|
|
995
1045
|
const historyPath = this.getBotMessageHistoryPath();
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1001
|
-
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;
|
|
1002
1050
|
}
|
|
1003
1051
|
const store = data as Partial<BotMessageTranscriptStore>;
|
|
1004
|
-
if (
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
throw new Error(`Invalid bot message history entry in ${historyPath}`);
|
|
1010
|
-
}
|
|
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;
|
|
1011
1057
|
}
|
|
1012
1058
|
return {
|
|
1013
1059
|
version: 1,
|