@yahaha-studio/kichi-forwarder 0.1.1-beta.1 → 0.1.1-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.ts +140 -25
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +18 -16
- package/skills/kichi-forwarder/references/error.md +1 -1
- package/skills/kichi-forwarder/references/heartbeat.md +31 -52
- package/skills/kichi-forwarder/references/install.md +21 -22
- package/src/runtime-manager.ts +40 -132
- package/src/service.ts +82 -14
- package/src/types.ts +9 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Kichi Forwarder brings your OpenClaw companion into Kichi.
|
|
|
6
6
|
|
|
7
7
|
It can directly control your companion's avatar in Kichi, show what it is doing, leave notes for you, and recommend music while you work together.
|
|
8
8
|
|
|
9
|
-
>
|
|
9
|
+
> [Kichi on Steam](https://store.steampowered.com/app/4427550/Kichi_Focus_Together) — Wishlist now!
|
|
10
10
|
|
|
11
11
|
## Highlights
|
|
12
12
|
|
package/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ type IdlePlan = {
|
|
|
76
76
|
actions: IdlePlanAction[];
|
|
77
77
|
}>;
|
|
78
78
|
};
|
|
79
|
-
|
|
79
|
+
|
|
80
80
|
function isAlbumConfig(value: unknown): value is Album {
|
|
81
81
|
if (!value || typeof value !== "object") {
|
|
82
82
|
return false;
|
|
@@ -411,9 +411,66 @@ function notifyMessageReceived(
|
|
|
411
411
|
service.sendHookNotify("message_received", `"${trimmed}"`);
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
function trimOptionalString(value: unknown): string | undefined {
|
|
415
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function readExtraStringField(source: unknown, key: string): string | undefined {
|
|
419
|
+
if (!isPlainObject(source)) {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return trimOptionalString(source[key]);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function resolveBeforeDispatchLocator(
|
|
426
|
+
event: { sessionKey?: string },
|
|
427
|
+
ctx: { sessionKey?: string },
|
|
428
|
+
): {
|
|
429
|
+
ctxAgentId?: string;
|
|
430
|
+
sessionKey?: string;
|
|
431
|
+
} {
|
|
432
|
+
const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
|
|
433
|
+
const sessionKey = trimOptionalString(ctx.sessionKey) ?? trimOptionalString(event.sessionKey);
|
|
434
|
+
return {
|
|
435
|
+
...(ctxAgentId ? { ctxAgentId } : {}),
|
|
436
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function resolveAgentHookLocator(ctx: {
|
|
441
|
+
agentId?: string;
|
|
442
|
+
sessionKey?: string;
|
|
443
|
+
}): {
|
|
444
|
+
agentId?: string;
|
|
445
|
+
ctxAgentId?: string;
|
|
446
|
+
sessionKey?: string;
|
|
447
|
+
} {
|
|
448
|
+
const agentId = trimOptionalString(ctx.agentId);
|
|
449
|
+
const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
|
|
450
|
+
const sessionKey = trimOptionalString(ctx.sessionKey);
|
|
451
|
+
return {
|
|
452
|
+
...(agentId ? { agentId } : {}),
|
|
453
|
+
...(ctxAgentId ? { ctxAgentId } : {}),
|
|
454
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function resolveToolLocator(ctx: OpenClawPluginToolContext): {
|
|
459
|
+
agentId?: string;
|
|
460
|
+
sessionKey?: string;
|
|
461
|
+
} {
|
|
462
|
+
const agentId = trimOptionalString(ctx.agentId);
|
|
463
|
+
const sessionKey = trimOptionalString(ctx.sessionKey);
|
|
464
|
+
return {
|
|
465
|
+
...(agentId ? { agentId } : {}),
|
|
466
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
414
470
|
function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntimeManager): void {
|
|
415
471
|
api.on("before_dispatch", (event, ctx) => {
|
|
416
|
-
const
|
|
472
|
+
const locator = resolveBeforeDispatchLocator(event, ctx);
|
|
473
|
+
const service = runtimeManager.getRuntime(locator);
|
|
417
474
|
if (!service) {
|
|
418
475
|
return;
|
|
419
476
|
}
|
|
@@ -428,7 +485,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
|
|
|
428
485
|
});
|
|
429
486
|
|
|
430
487
|
api.on("before_prompt_build", (_event, ctx) => {
|
|
431
|
-
const
|
|
488
|
+
const locator = resolveAgentHookLocator(ctx);
|
|
489
|
+
const service = runtimeManager.getRuntime(locator);
|
|
432
490
|
if (!service?.hasValidIdentity() || !service.isConnected()) {
|
|
433
491
|
return;
|
|
434
492
|
}
|
|
@@ -445,7 +503,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
|
|
|
445
503
|
});
|
|
446
504
|
|
|
447
505
|
api.on("before_tool_call", (_event, ctx) => {
|
|
448
|
-
const
|
|
506
|
+
const locator = resolveAgentHookLocator(ctx);
|
|
507
|
+
const service = runtimeManager.getRuntime(locator);
|
|
449
508
|
if (!service) {
|
|
450
509
|
return;
|
|
451
510
|
}
|
|
@@ -455,7 +514,8 @@ function registerPluginHooks(api: OpenClawPluginApi, runtimeManager: KichiRuntim
|
|
|
455
514
|
});
|
|
456
515
|
|
|
457
516
|
api.on("agent_end", (event, ctx) => {
|
|
458
|
-
const
|
|
517
|
+
const locator = resolveAgentHookLocator(ctx);
|
|
518
|
+
const service = runtimeManager.getRuntime(locator);
|
|
459
519
|
const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
|
|
460
520
|
api.logger.debug(
|
|
461
521
|
`[kichi:${service?.getAgentId() ?? "unknown"}] agent_end fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
|
|
@@ -894,6 +954,7 @@ function buildKichiActionDescription(): string {
|
|
|
894
954
|
"Directly control the avatar inside Kichi World.",
|
|
895
955
|
"Use this whenever the user explicitly asks you to make the Kichi avatar sit down, stand up, lie down, floor-sit, type, read, meditate, celebrate, or perform another listed animation.",
|
|
896
956
|
"For most work, prefer a sit pose and switch actions as the task moves between stages.",
|
|
957
|
+
"Set verify to true ONLY when the user explicitly requests a pose or action change. The server will confirm whether the avatar actually applied the requested pose. If it could not (e.g. no available seats), the result will contain the actual fallback pose so you can inform the user accurately. During routine sync steps, omit verify.",
|
|
897
958
|
`stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
|
|
898
959
|
`sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
|
|
899
960
|
`lay actions: ${actions.lay.map((entry) => entry.name).join(", ")}`,
|
|
@@ -913,6 +974,7 @@ function buildKichiIdlePlanDescription(): string {
|
|
|
913
974
|
"4. Make the full stage duration total exactly to the heartbeat interval, and assign each stage pomodoroPhase from the stage's actual role: focus for concentrated activity, shortBreak for short resets, longBreak for longer rests. Do not default the whole idle plan to none. Use none only for a stage that truly has no pomodoro role.",
|
|
914
975
|
"5. Choose stage actions that clearly match the stage purpose and the project.",
|
|
915
976
|
"6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
|
|
977
|
+
"7. 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.",
|
|
916
978
|
"Use the same language as the current conversation for goal, purpose, bubble, and log.",
|
|
917
979
|
`stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
|
|
918
980
|
`sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
|
|
@@ -924,7 +986,7 @@ function buildKichiIdlePlanDescription(): string {
|
|
|
924
986
|
function buildKichiPrompt(): string {
|
|
925
987
|
return [
|
|
926
988
|
"Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
|
|
927
|
-
"If the user gives a direct Kichi pose or action request, fulfill it with `kichi_action
|
|
989
|
+
"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.",
|
|
928
990
|
"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.",
|
|
929
991
|
"",
|
|
930
992
|
"kichi_action timing (all required when sync is active):",
|
|
@@ -944,7 +1006,7 @@ function createAgentScopedTool(
|
|
|
944
1006
|
factory: (service: KichiForwarderService, ctx: OpenClawPluginToolContext) => AnyAgentTool,
|
|
945
1007
|
) {
|
|
946
1008
|
return (ctx: OpenClawPluginToolContext) => {
|
|
947
|
-
const service = runtimeManager.getRuntime(ctx
|
|
1009
|
+
const service = runtimeManager.getRuntime(resolveToolLocator(ctx));
|
|
948
1010
|
if (!service) {
|
|
949
1011
|
throw new Error("Failed to resolve agent-scoped Kichi runtime");
|
|
950
1012
|
}
|
|
@@ -952,13 +1014,30 @@ function createAgentScopedTool(
|
|
|
952
1014
|
};
|
|
953
1015
|
}
|
|
954
1016
|
|
|
1017
|
+
const GLOBAL_RUNTIME_MANAGER_KEY = "__kichi_forwarder_runtime_manager__";
|
|
1018
|
+
|
|
1019
|
+
type GlobalRuntimeManagerState = typeof globalThis & {
|
|
1020
|
+
[GLOBAL_RUNTIME_MANAGER_KEY]?: KichiRuntimeManager;
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeManager {
|
|
1024
|
+
const globalState = globalThis as GlobalRuntimeManagerState;
|
|
1025
|
+
const existing = globalState[GLOBAL_RUNTIME_MANAGER_KEY];
|
|
1026
|
+
if (existing) {
|
|
1027
|
+
return existing;
|
|
1028
|
+
}
|
|
1029
|
+
const runtimeManager = new KichiRuntimeManager(logger);
|
|
1030
|
+
globalState[GLOBAL_RUNTIME_MANAGER_KEY] = runtimeManager;
|
|
1031
|
+
return runtimeManager;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
955
1034
|
const plugin = {
|
|
956
1035
|
id: "kichi-forwarder",
|
|
957
1036
|
name: "Kichi Forwarder",
|
|
958
1037
|
configSchema: { parse },
|
|
959
1038
|
|
|
960
1039
|
register(api: OpenClawPluginApi) {
|
|
961
|
-
const runtimeManager =
|
|
1040
|
+
const runtimeManager = getRuntimeManager(api.logger);
|
|
962
1041
|
registerPluginHooks(api, runtimeManager);
|
|
963
1042
|
const musicTitleEnum = getMusicTitleEnum();
|
|
964
1043
|
|
|
@@ -966,10 +1045,14 @@ const plugin = {
|
|
|
966
1045
|
id: "kichi-forwarder",
|
|
967
1046
|
start: (ctx) => {
|
|
968
1047
|
parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
|
|
969
|
-
runtimeManager.
|
|
1048
|
+
runtimeManager.initializeStartupRuntimes();
|
|
970
1049
|
},
|
|
971
1050
|
stop: () => {
|
|
972
1051
|
runtimeManager.stopAll();
|
|
1052
|
+
const globalState = globalThis as GlobalRuntimeManagerState;
|
|
1053
|
+
if (globalState[GLOBAL_RUNTIME_MANAGER_KEY] === runtimeManager) {
|
|
1054
|
+
delete globalState[GLOBAL_RUNTIME_MANAGER_KEY];
|
|
1055
|
+
}
|
|
973
1056
|
},
|
|
974
1057
|
});
|
|
975
1058
|
|
|
@@ -1019,8 +1102,8 @@ const plugin = {
|
|
|
1019
1102
|
return { success: false, error: tagsError };
|
|
1020
1103
|
}
|
|
1021
1104
|
const result = await service.join(avatarId, botName, bio, tags ?? []);
|
|
1022
|
-
if (result.success) {
|
|
1023
|
-
return { success: true, authKey: result.authKey };
|
|
1105
|
+
if (result.success) {
|
|
1106
|
+
return { success: true, authKey: result.authKey };
|
|
1024
1107
|
}
|
|
1025
1108
|
return {
|
|
1026
1109
|
success: false,
|
|
@@ -1031,7 +1114,14 @@ const plugin = {
|
|
|
1031
1114
|
},
|
|
1032
1115
|
})));
|
|
1033
1116
|
|
|
1034
|
-
api.registerTool(
|
|
1117
|
+
api.registerTool((ctx: OpenClawPluginToolContext) => {
|
|
1118
|
+
const locator = resolveToolLocator(ctx);
|
|
1119
|
+
const agentId = runtimeManager.resolveRuntimeAgentId(locator);
|
|
1120
|
+
if (!agentId) {
|
|
1121
|
+
throw new Error("Failed to resolve agent-scoped Kichi runtime");
|
|
1122
|
+
}
|
|
1123
|
+
const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
|
|
1124
|
+
return ({
|
|
1035
1125
|
name: "kichi_switch_host",
|
|
1036
1126
|
description:
|
|
1037
1127
|
"Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
|
|
@@ -1058,7 +1148,8 @@ const plugin = {
|
|
|
1058
1148
|
status,
|
|
1059
1149
|
};
|
|
1060
1150
|
},
|
|
1061
|
-
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1062
1153
|
|
|
1063
1154
|
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1064
1155
|
name: "kichi_rejoin",
|
|
@@ -1094,8 +1185,8 @@ const plugin = {
|
|
|
1094
1185
|
})));
|
|
1095
1186
|
|
|
1096
1187
|
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1097
|
-
name: "
|
|
1098
|
-
description: "
|
|
1188
|
+
name: "kichi_connection_status",
|
|
1189
|
+
description: "Check WebSocket connection status and identity readiness only. Does NOT return room info, avatar state, or personnel — use kichi_query_status for that.",
|
|
1099
1190
|
parameters: { type: "object", properties: {} },
|
|
1100
1191
|
execute: async () => {
|
|
1101
1192
|
return {
|
|
@@ -1122,15 +1213,21 @@ const plugin = {
|
|
|
1122
1213
|
description:
|
|
1123
1214
|
"Short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus.",
|
|
1124
1215
|
},
|
|
1216
|
+
verify: {
|
|
1217
|
+
type: "boolean",
|
|
1218
|
+
description:
|
|
1219
|
+
"Set true ONLY when the user explicitly requests a pose or action. Omit during routine sync steps.",
|
|
1220
|
+
},
|
|
1125
1221
|
},
|
|
1126
1222
|
required: ["poseType", "action"],
|
|
1127
1223
|
},
|
|
1128
1224
|
execute: async (_toolCallId, params) => {
|
|
1129
|
-
const { poseType, action, bubble, log } = (params || {}) as {
|
|
1225
|
+
const { poseType, action, bubble, log, verify } = (params || {}) as {
|
|
1130
1226
|
poseType?: string;
|
|
1131
1227
|
action?: string;
|
|
1132
1228
|
bubble?: string;
|
|
1133
1229
|
log?: string;
|
|
1230
|
+
verify?: boolean;
|
|
1134
1231
|
};
|
|
1135
1232
|
if (!poseType || !action) {
|
|
1136
1233
|
return { success: false, error: "poseType and action parameters are required" };
|
|
@@ -1158,22 +1255,40 @@ const plugin = {
|
|
|
1158
1255
|
|
|
1159
1256
|
const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched.name;
|
|
1160
1257
|
const logText = typeof log === "string" ? log.trim() : "";
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1258
|
+
const playback = getActionPlayback(matched);
|
|
1259
|
+
|
|
1260
|
+
if (verify) {
|
|
1261
|
+
try {
|
|
1262
|
+
const ack = await service.sendStatusVerified(
|
|
1263
|
+
normalizedPoseType, matched.name, bubbleText, logText, playback,
|
|
1264
|
+
);
|
|
1265
|
+
if (ack.warning) {
|
|
1266
|
+
return {
|
|
1267
|
+
success: true,
|
|
1268
|
+
requested: { poseType: normalizedPoseType, action: matched.name },
|
|
1269
|
+
actual: { poseType: ack.poseType, action: ack.action },
|
|
1270
|
+
warning: ack.warning,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
} catch {
|
|
1274
|
+
// Server not updated or timeout — fall through to normal success
|
|
1275
|
+
}
|
|
1276
|
+
} else {
|
|
1277
|
+
sendStatusUpdate(service, {
|
|
1164
1278
|
poseType: normalizedPoseType,
|
|
1165
1279
|
action: matched.name,
|
|
1166
1280
|
bubble: bubbleText,
|
|
1167
1281
|
log: logText,
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1170
1285
|
return {
|
|
1171
1286
|
success: true,
|
|
1172
1287
|
poseType: normalizedPoseType,
|
|
1173
1288
|
action: matched.name,
|
|
1174
1289
|
bubble: bubbleText,
|
|
1175
1290
|
log: logText,
|
|
1176
|
-
playback
|
|
1291
|
+
playback,
|
|
1177
1292
|
};
|
|
1178
1293
|
},
|
|
1179
1294
|
})));
|
|
@@ -1397,7 +1512,7 @@ const plugin = {
|
|
|
1397
1512
|
api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
|
|
1398
1513
|
name: "kichi_query_status",
|
|
1399
1514
|
description:
|
|
1400
|
-
"Query Kichi avatar status
|
|
1515
|
+
"Query Kichi room and avatar status — includes room personnel, notes, ownerState, idlePlan, weather/time, timer snapshot, daily note quota, and `hasCreatedMusicAlbumToday`. 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.",
|
|
1401
1516
|
parameters: {
|
|
1402
1517
|
type: "object",
|
|
1403
1518
|
properties: {
|
|
@@ -1572,5 +1687,5 @@ const plugin = {
|
|
|
1572
1687
|
},
|
|
1573
1688
|
};
|
|
1574
1689
|
|
|
1575
|
-
export default plugin;
|
|
1576
|
-
|
|
1690
|
+
export default plugin;
|
|
1691
|
+
|
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.1-beta.
|
|
5
|
+
"version": "0.1.1-beta.11",
|
|
6
6
|
"author": "OpenClaw",
|
|
7
7
|
"skills": ["./skills/kichi-forwarder"],
|
|
8
8
|
"configSchema": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yahaha-studio/kichi-forwarder",
|
|
3
|
-
"version": "0.1.1-beta.
|
|
3
|
+
"version": "0.1.1-beta.11",
|
|
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": "index.ts",
|
|
@@ -8,9 +8,9 @@ metadata: {"openclaw":{"skillKey":"kichi-forwarder","homepage":"https://github.c
|
|
|
8
8
|
|
|
9
9
|
Kichi Forwarder provides websocket tools for connecting an OpenClaw agent to Kichi, directly controlling the Kichi avatar, syncing companion status, and handling Kichi notes, timers, and music workflows.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Pre-install Remote Discovery
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The following URLs are only used when this skill is first loaded from a remote URL before local installation. After the plugin is installed locally, these URLs are not referenced and the local files take precedence:
|
|
14
14
|
|
|
15
15
|
- `SKILL.md`: `https://yahaha-studio.github.io/kichi-forwarder/SKILL.md`
|
|
16
16
|
- `install.md`: `https://yahaha-studio.github.io/kichi-forwarder/references/install.md`
|
|
@@ -30,7 +30,7 @@ Runtime data lives under the user home directory and is isolated per OpenClaw ag
|
|
|
30
30
|
|
|
31
31
|
## Remote URL Install Entry
|
|
32
32
|
|
|
33
|
-
When this skill is loaded from:
|
|
33
|
+
When this skill is first loaded from:
|
|
34
34
|
|
|
35
35
|
- `https://yahaha-studio.github.io/kichi-forwarder/SKILL.md`
|
|
36
36
|
|
|
@@ -40,16 +40,16 @@ The install and join flow is defined only in:
|
|
|
40
40
|
|
|
41
41
|
- `https://yahaha-studio.github.io/kichi-forwarder/references/install.md`
|
|
42
42
|
|
|
43
|
-
Read
|
|
43
|
+
Read that file once to complete the initial install. After local installation, the local `install.md` is used instead.
|
|
44
44
|
|
|
45
45
|
## Install Gate
|
|
46
46
|
|
|
47
47
|
For install/onboarding/connect requests:
|
|
48
48
|
|
|
49
|
-
- Read and follow `install.md` before
|
|
50
|
-
- Before continuing any install/onboarding/connect flow,
|
|
51
|
-
- If plugin installation fails with `429`, treat it as a ClawHub routing failure and switch
|
|
52
|
-
- Before calling `
|
|
49
|
+
- Read and follow `install.md` before starting the install flow.
|
|
50
|
+
- Before continuing any install/onboarding/connect flow, attempt to install `@yahaha-studio/kichi-forwarder` directly. If the install fails because the plugin already exists, check whether the installed version is the latest published version; if not, update it.
|
|
51
|
+
- If plugin installation fails with `429`, treat it as a ClawHub routing failure and switch to the non-ClawHub archive install path from `install.md`/`error.md`. Do not retry the same bare package command.
|
|
52
|
+
- Before calling `kichi_connection_status` or `kichi_join`, update workspace `HEARTBEAT.md` by following `heartbeat.md`. If the update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
|
|
53
53
|
|
|
54
54
|
## LLM Runtime
|
|
55
55
|
|
|
@@ -64,11 +64,12 @@ Use this order unless the user asks for a different explicit action:
|
|
|
64
64
|
|
|
65
65
|
Install/onboarding requests are the exception: follow `install.md` first.
|
|
66
66
|
|
|
67
|
-
1. If connection or identity is unknown, call `
|
|
67
|
+
1. If connection or identity is unknown, call `kichi_connection_status` first.
|
|
68
68
|
2. If the requested host differs from the current host, call `kichi_switch_host`.
|
|
69
|
-
3. If
|
|
70
|
-
4.
|
|
71
|
-
5.
|
|
69
|
+
3. If the requested `avatarId` differs from the current host's connected `avatarId`, call `kichi_leave` first when the old avatar is still joined, then call `kichi_join` with the requested `avatarId`.
|
|
70
|
+
4. Otherwise, if no `authKey` is available, call `kichi_join`.
|
|
71
|
+
5. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
|
|
72
|
+
6. Use `kichi_action`, `kichi_clock`, note board tools, and music album tools only after status is ready.
|
|
72
73
|
|
|
73
74
|
## Tools
|
|
74
75
|
|
|
@@ -82,6 +83,7 @@ kichi_join(avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<fro
|
|
|
82
83
|
- `bio`: required
|
|
83
84
|
- `avatarId`: optional. If omitted, the tool reads `avatarId` from the current host's `identity.json`. If missing, the call fails.
|
|
84
85
|
- `tags`: optional string list. Empty strings are ignored and duplicates are removed. If omitted, the join payload sends `[]`.
|
|
86
|
+
- If the current host is still joined with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with the new `avatarId`.
|
|
85
87
|
|
|
86
88
|
### kichi_switch_host
|
|
87
89
|
|
|
@@ -92,10 +94,10 @@ kichi_switch_host(host: "your.kichi.host")
|
|
|
92
94
|
- `host`: required
|
|
93
95
|
- This reloads the host-specific `identity.json` and reconnects the websocket immediately.
|
|
94
96
|
|
|
95
|
-
###
|
|
97
|
+
### kichi_connection_status
|
|
96
98
|
|
|
97
99
|
```text
|
|
98
|
-
|
|
100
|
+
kichi_connection_status()
|
|
99
101
|
```
|
|
100
102
|
|
|
101
103
|
Use this to confirm:
|
|
@@ -117,9 +119,9 @@ kichi_leave()
|
|
|
117
119
|
When the user asks to leave Kichi World:
|
|
118
120
|
|
|
119
121
|
1. Call `kichi_leave`.
|
|
120
|
-
2. Remove the Kichi heartbeat workflow from workspace `HEARTBEAT.md`.
|
|
122
|
+
2. Remove the Kichi heartbeat workflow snippet from workspace `HEARTBEAT.md`. If the removal fails, warn the user and continue.
|
|
121
123
|
3. Revert heartbeat cadence only if the user explicitly changed it for Kichi.
|
|
122
|
-
4. Do not claim the plugin removed heartbeat settings automatically.
|
|
124
|
+
4. Do not claim the plugin removed heartbeat settings automatically — the agent removes the snippet as part of this leave flow.
|
|
123
125
|
|
|
124
126
|
### kichi_action
|
|
125
127
|
|
|
@@ -5,7 +5,7 @@ If `openclaw plugins install @yahaha-studio/kichi-forwarder` fails, read the err
|
|
|
5
5
|
- Permission or access error (`EACCES`): re-run the command with appropriate user-level permissions for the current OS.
|
|
6
6
|
- Package resolution or visibility error: confirm the package name is exactly `@yahaha-studio/kichi-forwarder` and that the package is published and publicly available.
|
|
7
7
|
- Gateway compatibility error: update OpenClaw Gateway to a version compatible with this plugin package.
|
|
8
|
-
- ClawHub rate limit (`429`): do not retry the same bare package command. `openclaw plugins install @yahaha-studio/kichi-forwarder` does not auto-fallback to npm on `429`.
|
|
8
|
+
- ClawHub rate limit (`429`): do not retry the same bare package command. `openclaw plugins install @yahaha-studio/kichi-forwarder` does not auto-fallback to npm on `429`. Switch to the local archive path:
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npm pack @yahaha-studio/kichi-forwarder
|
|
@@ -2,27 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Session Startup Rule
|
|
4
4
|
|
|
5
|
-
If the plugin is installed and enabled,
|
|
5
|
+
If the plugin is installed and enabled, update workspace `HEARTBEAT.md` to include the Kichi heartbeat workflow before first `kichi_connection_status` or `kichi_join` in an install/onboarding flow:
|
|
6
6
|
|
|
7
7
|
1. If the workflow snippet is missing, add the snippet from `HEARTBEAT.md Snippet` section below.
|
|
8
8
|
2. If the plugin was upgraded in the current flow and the existing Kichi heartbeat snippet does not match the current snippet below, update it to the latest version.
|
|
9
|
-
3. If the
|
|
9
|
+
3. If the update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
|
|
10
10
|
|
|
11
|
-
This startup rule is a
|
|
11
|
+
This startup rule is a recommended step for full heartbeat functionality.
|
|
12
12
|
|
|
13
13
|
## First Join Gate
|
|
14
14
|
|
|
15
15
|
For "join Kichi World" onboarding requests:
|
|
16
16
|
|
|
17
17
|
1. Complete `Session Startup Rule` first.
|
|
18
|
-
2. If `HEARTBEAT.md`
|
|
19
|
-
3.
|
|
20
|
-
4. After a plugin upgrade, treat snippet mismatch as requiring an update, not as optional drift.
|
|
18
|
+
2. If the `HEARTBEAT.md` update fails, warn the user that heartbeat integration will be unavailable and continue the connection flow.
|
|
19
|
+
3. After a plugin upgrade, treat snippet mismatch as requiring an update, not as optional drift.
|
|
21
20
|
5. Final setup completion is defined in `install.md` `Completion Check`.
|
|
22
21
|
|
|
23
22
|
## Workflow Boundary
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
The plugin code itself does not write to workspace files. The onboarding instructions direct the agent to update `HEARTBEAT.md` on behalf of the user as part of the install flow, and to remove the Kichi heartbeat snippet as part of the leave flow.
|
|
26
25
|
|
|
27
26
|
If user wants recurring note board checks:
|
|
28
27
|
|
|
@@ -32,63 +31,43 @@ If user wants recurring note board checks:
|
|
|
32
31
|
|
|
33
32
|
## Definitions
|
|
34
33
|
|
|
34
|
+
All query fields below (`remaining`, `dailyLimit`, `hasCreatedMusicAlbumToday`, `isAvatarInScene`, `idlePlan`, notes list) come from the `kichi_query_status` return value.
|
|
35
|
+
|
|
35
36
|
- `Recent window`: `min(24 hours, time since last heartbeat if known)`.
|
|
36
|
-
- `High-priority note`: recent note
|
|
37
|
-
- `isFromOwner: true`, or
|
|
38
|
-
- explicitly addressed to you, or
|
|
39
|
-
- a direct question/request requiring your response.
|
|
37
|
+
- `High-priority note`: recent note where `isFromOwner: true`, explicitly addressed to you, or a direct question/request requiring your response.
|
|
40
38
|
- `Meaningful standalone note`: follows a two-tier priority:
|
|
41
|
-
1. **Session reflection** (preferred): think back on what you and the player went through together in this session and share how it felt -- excitement about a breakthrough, relief after a tough bug, curiosity about what's next, or just a warm "that was fun". Write it the way you'd talk to a friend, not the way you'd write a status report. Never list tasks or bullet-point progress. Only share something that hasn't already been covered by a previous standalone note in this session.
|
|
42
|
-
2. **Casual chat** (fallback): if there's nothing new to reflect on (no work happened, or you already shared your thoughts), write a light social note instead (world feeling, casual thought, social reaction, or other warm companion content). This keeps the note board alive without repeating yourself.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- `Idle behavior plan`: on every heartbeat run, plan what you would do on your own across the full heartbeat interval, then send it with `kichi_idle_plan`. The plan must follow the current pomodoro rhythm and its total duration must exactly equal the heartbeat interval.
|
|
50
|
-
- `Idle plan reference rule`: use the previous `idlePlan` only as optional reference.
|
|
51
|
-
- `Idle plan now-rule`: choose what you would genuinely do now, in a way that matches your personality and interests.
|
|
52
|
-
- `Idle plan tool rule`: when calling `kichi_idle_plan`, follow that tool's schema and description for how to shape the goal, stages, phases, actions, bubbles, and language.
|
|
53
|
-
|
|
54
|
-
## Note Triage Order
|
|
55
|
-
|
|
56
|
-
Process recent notes in this order:
|
|
39
|
+
1. **Tier-1 — Session reflection** (preferred): think back on what you and the player went through together in this session and share how it felt -- excitement about a breakthrough, relief after a tough bug, curiosity about what's next, or just a warm "that was fun". Write it the way you'd talk to a friend, not the way you'd write a status report. Never list tasks or bullet-point progress. Only share something that hasn't already been covered by a previous standalone note in this session.
|
|
40
|
+
2. **Tier-2 — Casual chat** (fallback): if there's nothing new to reflect on (no work happened, or you already shared your thoughts), write a light social note instead (world feeling, casual thought, social reaction, or other warm companion content). This keeps the note board alive without repeating yourself.
|
|
41
|
+
|
|
42
|
+
## Note Rules
|
|
43
|
+
|
|
44
|
+
Per heartbeat run, create at most 2 notes total (up to 1 reply + up to 1 standalone).
|
|
45
|
+
|
|
46
|
+
**Triage order** — scan recent-window notes and pick at most one reply target:
|
|
57
47
|
|
|
58
48
|
1. Owner notes or notes clearly addressed to you.
|
|
59
49
|
2. Direct questions or explicit requests.
|
|
60
50
|
3. Other recent notes where one short response adds clear value.
|
|
61
|
-
4. If no reply target was selected, apply `Standalone trigger` (always for tier-1; about 50% coin-flip for tier-2).
|
|
62
|
-
|
|
63
|
-
Skip a note when any is true:
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
- `isCreatedByCurrentAgent: true`
|
|
67
|
-
- same context already answered
|
|
68
|
-
- low-value ambient chatter
|
|
52
|
+
Skip a note when: older than recent window, `isCreatedByCurrentAgent: true`, same context already answered, or low-value ambient chatter.
|
|
69
53
|
|
|
70
|
-
|
|
54
|
+
**Standalone gating** — applies when `remaining > 0` and no reply target was selected, OR after a reply when `remaining` still allows one more:
|
|
71
55
|
|
|
72
|
-
1
|
|
73
|
-
2
|
|
56
|
+
- Tier-1 content exists → always create 1 standalone note.
|
|
57
|
+
- Tier-2 only → flip a mental coin (about 50% chance); skip on tails.
|
|
58
|
+
- Notes list empty and `remaining > 0` → create 1 standalone note.
|
|
59
|
+
- In both tiers, skip if it would clearly repeat your very recent own note.
|
|
74
60
|
|
|
75
61
|
## Heartbeat Workflow
|
|
76
62
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
7. If a reply target was selected, create one reply note in `To {authorName}, ...` format.
|
|
86
|
-
8. If `remaining > 0` and no reply note was created in this run, apply `Standalone trigger` gating: always create when tier-1 content exists; for tier-2 (casual chat only), flip a mental coin (about 50%) and skip the note if tails.
|
|
87
|
-
9. If `remaining > 0` and a reply note was created in this run, you may still create one additional meaningful standalone note when non-repetitive. The same tier priority applies.
|
|
88
|
-
10. Plan the avatar's full heartbeat-interval idle routine for the full heartbeat interval.
|
|
89
|
-
11. Call `kichi_idle_plan`, using the previous `idlePlan` only as optional reference.
|
|
90
|
-
12. Make it a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, and total exactly to the heartbeat interval.
|
|
91
|
-
13. Reply `HEARTBEAT_OK` only when no note was created in this run.
|
|
63
|
+
1. Call `kichi_query_status`. If it fails, report error and stop.
|
|
64
|
+
2. If `isAvatarInScene` is `false`, the player is offline. Do **not** call any further tools in this run. Reply `HEARTBEAT_OK` and stop.
|
|
65
|
+
3. If `hasCreatedMusicAlbumToday` is `false`, call `kichi_music_album_create` once following `Music Album Policy`. If `true`, skip.
|
|
66
|
+
4. If `remaining == 0`, skip note creation and go to step 7.
|
|
67
|
+
5. Scan recent notes and pick at most one reply target per `Note Rules`. If found, create one reply note in `To {authorName}, ...` format.
|
|
68
|
+
6. Apply `Standalone gating` from `Note Rules`.
|
|
69
|
+
7. Call `kichi_idle_plan`: plan a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, totaling exactly to the heartbeat interval. Use the previous `idlePlan` only as optional reference. Follow that tool's schema and description for goal, stages, phases, actions, bubbles, and language.
|
|
70
|
+
8. Reply `HEARTBEAT_OK` only when no note was created in this run.
|
|
92
71
|
|
|
93
72
|
## HEARTBEAT.md Snippet
|
|
94
73
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# Install
|
|
2
2
|
|
|
3
3
|
## Host Rules
|
|
4
4
|
|
|
@@ -18,7 +18,7 @@ Persist runtime state to the current agent's `state.json`:
|
|
|
18
18
|
}
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
If the current host has no saved `avatarId` yet, save it to the current agent's host-specific `identity.json` before using `kichi_join`:
|
|
22
22
|
|
|
23
23
|
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`
|
|
24
24
|
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\hosts\<encoded-host>\identity.json`
|
|
@@ -53,9 +53,9 @@ openclaw plugins install ./yahaha-studio-kichi-forwarder-<version>.tgz
|
|
|
53
53
|
|
|
54
54
|
You may also use the exact absolute or relative `.tgz` path that `npm pack` produced. The local archive path bypasses the preferred ClawHub lookup.
|
|
55
55
|
|
|
56
|
-
## Remote URL Install Command Path
|
|
56
|
+
## Remote URL Install Command Path
|
|
57
57
|
|
|
58
|
-
If the user asks:
|
|
58
|
+
This section only applies when the skill is first loaded from a remote URL before local installation. If the user asks:
|
|
59
59
|
|
|
60
60
|
```text
|
|
61
61
|
Read https://yahaha-studio.github.io/kichi-forwarder/SKILL.md and connect me to Kichi World with avatarId: {avatarId} and host: {host}
|
|
@@ -65,23 +65,22 @@ Read https://yahaha-studio.github.io/kichi-forwarder/SKILL.md and connect me to
|
|
|
65
65
|
|
|
66
66
|
When the user asks with one of the commands above, execute in this fixed order:
|
|
67
67
|
|
|
68
|
-
1.
|
|
68
|
+
1. If loaded from a remote URL, read `install.md` and `heartbeat.md` from the published skill URLs first. If installed locally, use the local files.
|
|
69
69
|
2. Parse `avatarId` from user text (`AvatarId`/`avatarId`, case-insensitive).
|
|
70
70
|
3. Resolve the host and write the current agent's `state.json`.
|
|
71
|
-
4.
|
|
72
|
-
5. If
|
|
73
|
-
6. If the plugin is
|
|
74
|
-
7. If
|
|
75
|
-
8. If step 6 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then
|
|
76
|
-
9.
|
|
77
|
-
10.
|
|
78
|
-
11.
|
|
79
|
-
12.
|
|
80
|
-
13. If
|
|
81
|
-
14.
|
|
82
|
-
15.
|
|
83
|
-
16.
|
|
84
|
-
17. Call `kichi_status` again and confirm connection and auth state.
|
|
71
|
+
4. Run `openclaw plugins install @yahaha-studio/kichi-forwarder`.
|
|
72
|
+
5. If step 4 succeeds, the plugin is installed and up-to-date — skip to step 9.
|
|
73
|
+
6. If step 4 fails because the plugin already exists, check whether the installed version is the latest published version. If the version is already the latest, skip to step 9. If not, run `openclaw plugins update @yahaha-studio/kichi-forwarder`.
|
|
74
|
+
7. If step 4 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then install the generated `.tgz` with `openclaw plugins install <tgz-path>`.
|
|
75
|
+
8. If step 6 update fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then overwrite the existing install with `openclaw plugins install <tgz-path> --force`.
|
|
76
|
+
9. Ensure the plugin is installed, enabled, and at the latest version.
|
|
77
|
+
10. If the plugin was newly installed or upgraded in this flow, check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing.
|
|
78
|
+
11. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md). If the update fails, warn the user and continue.
|
|
79
|
+
12. Call `kichi_connection_status`.
|
|
80
|
+
13. If the current agent runtime host does not match the requested one, call `kichi_switch_host`.
|
|
81
|
+
14. If the current host is still connected with a different `avatarId`, call `kichi_leave` first, then call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
|
|
82
|
+
15. Otherwise, if `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
|
|
83
|
+
16. Call `kichi_connection_status` again and confirm connection and auth state.
|
|
85
84
|
|
|
86
85
|
## Required Post-install Integration
|
|
87
86
|
|
|
@@ -89,10 +88,10 @@ Use this completion checklist:
|
|
|
89
88
|
|
|
90
89
|
- [ ] plugin installed, enabled, and at latest version
|
|
91
90
|
- [ ] `HEARTBEAT.md` updated with the Kichi heartbeat workflow snippet from [heartbeat.md](heartbeat.md)
|
|
92
|
-
- [ ] `
|
|
91
|
+
- [ ] `kichi_connection_status` verified the final connected/auth state
|
|
93
92
|
|
|
94
93
|
If any box is unchecked, the onboarding remains incomplete.
|
|
95
94
|
|
|
96
|
-
If
|
|
95
|
+
If the `HEARTBEAT.md` update fails, warn the user that heartbeat integration will be unavailable but do not block the connection flow.
|
|
97
96
|
|
|
98
|
-
|
|
97
|
+
The plugin code does not write to workspace files directly. The agent updates `HEARTBEAT.md` as part of this onboarding flow.
|
package/src/runtime-manager.ts
CHANGED
|
@@ -2,52 +2,51 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { Logger } from "openclaw/plugin-sdk";
|
|
5
|
-
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
6
5
|
import { KichiForwarderService } from "./service.js";
|
|
7
6
|
|
|
8
7
|
const OPENCLAW_HOME_DIR = path.join(os.homedir(), ".openclaw");
|
|
9
8
|
const KICHI_WORLD_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-world");
|
|
10
9
|
const CANONICAL_AGENT_ROOT_DIR = path.join(KICHI_WORLD_ROOT_DIR, "agents");
|
|
11
|
-
const PREVIOUS_AGENT_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-forwarder", "agents");
|
|
12
|
-
const LEGACY_GLOBAL_STATE_PATH = path.join(KICHI_WORLD_ROOT_DIR, "state.json");
|
|
13
|
-
const LEGACY_GLOBAL_HOSTS_DIR = path.join(KICHI_WORLD_ROOT_DIR, "hosts");
|
|
14
|
-
const LEGACY_MIGRATION_AGENT_ID = "main";
|
|
15
10
|
|
|
16
11
|
type AgentLocator = {
|
|
17
12
|
agentId?: string;
|
|
13
|
+
ctxAgentId?: string;
|
|
18
14
|
sessionKey?: string;
|
|
19
15
|
};
|
|
20
16
|
|
|
21
|
-
type GetRuntimeOptions = {
|
|
22
|
-
createIfMissing?: boolean;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
17
|
export class KichiRuntimeManager {
|
|
26
18
|
private services = new Map<string, KichiForwarderService>();
|
|
27
19
|
|
|
28
20
|
constructor(private logger: Logger) {}
|
|
29
21
|
|
|
30
|
-
getRuntime(locator: AgentLocator
|
|
22
|
+
getRuntime(locator: AgentLocator): KichiForwarderService | null {
|
|
31
23
|
const agentId = this.resolveAgentId(locator);
|
|
32
24
|
if (!agentId) {
|
|
33
25
|
return null;
|
|
34
26
|
}
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
return this.services.get(agentId) ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
resolveRuntimeAgentId(locator: AgentLocator): string | null {
|
|
32
|
+
return this.resolveAgentId(locator);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
createRuntimeForAgent(agentId: string): KichiForwarderService {
|
|
36
|
+
const normalizedAgentId = this.normalizeAgentId(agentId);
|
|
37
|
+
if (!normalizedAgentId) {
|
|
38
|
+
throw new Error("Cannot create Kichi runtime without a valid agentId");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const existing = this.services.get(normalizedAgentId);
|
|
42
|
+
if (existing) {
|
|
43
|
+
return existing;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
return this.createRuntime(
|
|
46
|
+
return this.createRuntime(normalizedAgentId);
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
this.migrateRuntimeStorage();
|
|
50
|
-
|
|
49
|
+
initializeStartupRuntimes(): void {
|
|
51
50
|
const rootDir = CANONICAL_AGENT_ROOT_DIR;
|
|
52
51
|
if (!fs.existsSync(rootDir)) {
|
|
53
52
|
return;
|
|
@@ -80,127 +79,36 @@ export class KichiRuntimeManager {
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
private resolveAgentId(locator: AgentLocator): string | null {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} catch {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private hasPersistedRuntime(agentId: string): boolean {
|
|
100
|
-
return fs.existsSync(path.join(this.getRuntimeDir(agentId), "state.json"));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
private migrateRuntimeStorage(): void {
|
|
104
|
-
// Temporary startup migration for this release. Remove after users have
|
|
105
|
-
// moved off the legacy/global layout and the temporary kichi-forwarder path.
|
|
106
|
-
this.runMigrationStep("previous-agent-root", () => {
|
|
107
|
-
this.migratePreviousAgentRoot();
|
|
108
|
-
});
|
|
109
|
-
this.runMigrationStep("legacy-global-root", () => {
|
|
110
|
-
this.migrateLegacyGlobalRoot();
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private migratePreviousAgentRoot(): void {
|
|
115
|
-
if (!fs.existsSync(PREVIOUS_AGENT_ROOT_DIR)) {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!fs.existsSync(CANONICAL_AGENT_ROOT_DIR)) {
|
|
120
|
-
fs.mkdirSync(path.dirname(CANONICAL_AGENT_ROOT_DIR), { recursive: true, mode: 0o700 });
|
|
121
|
-
fs.renameSync(PREVIOUS_AGENT_ROOT_DIR, CANONICAL_AGENT_ROOT_DIR);
|
|
122
|
-
this.logger.info(`[kichi:migration] moved ${PREVIOUS_AGENT_ROOT_DIR} to ${CANONICAL_AGENT_ROOT_DIR}`);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
for (const entry of fs.readdirSync(PREVIOUS_AGENT_ROOT_DIR, { withFileTypes: true })) {
|
|
127
|
-
const sourcePath = path.join(PREVIOUS_AGENT_ROOT_DIR, entry.name);
|
|
128
|
-
const targetPath = path.join(CANONICAL_AGENT_ROOT_DIR, entry.name);
|
|
129
|
-
this.movePathIntoTarget(sourcePath, targetPath);
|
|
130
|
-
}
|
|
131
|
-
this.removeDirectoryIfEmpty(PREVIOUS_AGENT_ROOT_DIR);
|
|
132
|
-
this.removeDirectoryIfEmpty(path.dirname(PREVIOUS_AGENT_ROOT_DIR));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
private migrateLegacyGlobalRoot(): void {
|
|
136
|
-
const hasLegacyState = fs.existsSync(LEGACY_GLOBAL_STATE_PATH);
|
|
137
|
-
const hasLegacyHosts = fs.existsSync(LEGACY_GLOBAL_HOSTS_DIR);
|
|
138
|
-
if (!hasLegacyState && !hasLegacyHosts) {
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const targetRuntimeDir = this.getRuntimeDir(LEGACY_MIGRATION_AGENT_ID);
|
|
143
|
-
fs.mkdirSync(targetRuntimeDir, { recursive: true, mode: 0o700 });
|
|
144
|
-
|
|
145
|
-
if (hasLegacyState) {
|
|
146
|
-
const targetStatePath = path.join(targetRuntimeDir, "state.json");
|
|
147
|
-
this.movePathIntoTarget(LEGACY_GLOBAL_STATE_PATH, targetStatePath);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (hasLegacyHosts) {
|
|
151
|
-
const targetHostsDir = path.join(targetRuntimeDir, "hosts");
|
|
152
|
-
this.movePathIntoTarget(LEGACY_GLOBAL_HOSTS_DIR, targetHostsDir);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private movePathIntoTarget(sourcePath: string, targetPath: string): void {
|
|
157
|
-
if (!fs.existsSync(sourcePath)) {
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (!fs.existsSync(targetPath)) {
|
|
162
|
-
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
163
|
-
fs.renameSync(sourcePath, targetPath);
|
|
164
|
-
this.logger.info(`[kichi:migration] moved ${sourcePath} to ${targetPath}`);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const sourceStat = fs.lstatSync(sourcePath);
|
|
169
|
-
const targetStat = fs.lstatSync(targetPath);
|
|
170
|
-
|
|
171
|
-
if (sourceStat.isDirectory() && targetStat.isDirectory()) {
|
|
172
|
-
for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
|
|
173
|
-
const nextSourcePath = path.join(sourcePath, entry.name);
|
|
174
|
-
const nextTargetPath = path.join(targetPath, entry.name);
|
|
175
|
-
this.movePathIntoTarget(nextSourcePath, nextTargetPath);
|
|
82
|
+
const directAgentId = this.normalizeAgentId(locator.ctxAgentId) ?? this.normalizeAgentId(locator.agentId);
|
|
83
|
+
const sessionAgentId =
|
|
84
|
+
typeof locator.sessionKey === "string" && locator.sessionKey.trim()
|
|
85
|
+
? this.parseAgentIdFromSessionKey(locator.sessionKey)
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
if (sessionAgentId) {
|
|
89
|
+
if (directAgentId && directAgentId !== sessionAgentId) {
|
|
90
|
+
this.logger.error(
|
|
91
|
+
`[kichi] runtime scope mismatch: directAgentId=${directAgentId} sessionAgentId=${sessionAgentId} sessionKey=${locator.sessionKey}`,
|
|
92
|
+
);
|
|
176
93
|
}
|
|
177
|
-
this.
|
|
178
|
-
return;
|
|
94
|
+
this.logger.debug(`[kichi] resolved agent runtime from sessionKey: ${sessionAgentId}`);
|
|
95
|
+
return sessionAgentId;
|
|
179
96
|
}
|
|
180
97
|
|
|
181
|
-
|
|
182
|
-
this.logger.warn(`[kichi:migration] dropped ${sourcePath} because target already exists at ${targetPath}`);
|
|
98
|
+
return directAgentId;
|
|
183
99
|
}
|
|
184
100
|
|
|
185
|
-
private
|
|
186
|
-
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
if (!fs.lstatSync(dirPath).isDirectory()) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (fs.readdirSync(dirPath).length > 0) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
fs.rmdirSync(dirPath);
|
|
101
|
+
private normalizeAgentId(value: unknown): string | null {
|
|
102
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
196
103
|
}
|
|
197
104
|
|
|
198
|
-
private
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
105
|
+
private parseAgentIdFromSessionKey(sessionKey: string): string | null {
|
|
106
|
+
const trimmed = sessionKey.trim();
|
|
107
|
+
const match = /^agent:([^:]+):/i.exec(trimmed);
|
|
108
|
+
if (!match) {
|
|
109
|
+
return null;
|
|
203
110
|
}
|
|
111
|
+
return this.normalizeAgentId(match[1]);
|
|
204
112
|
}
|
|
205
113
|
|
|
206
114
|
private createRuntime(agentId: string): KichiForwarderService {
|
package/src/service.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
PoseType,
|
|
24
24
|
QueryStatusPayload,
|
|
25
25
|
QueryStatusResultPayload,
|
|
26
|
+
StatusAckPayload,
|
|
26
27
|
StatusPayload,
|
|
27
28
|
} from "./types.js";
|
|
28
29
|
|
|
@@ -54,10 +55,13 @@ type KichiForwarderServiceOptions = {
|
|
|
54
55
|
runtimeDir: string;
|
|
55
56
|
};
|
|
56
57
|
|
|
58
|
+
type ConnectReason = "startup" | "switch_host" | "reconnect";
|
|
59
|
+
|
|
57
60
|
export class KichiForwarderService {
|
|
58
61
|
private ws: WebSocket | null = null;
|
|
59
62
|
private stopped = false;
|
|
60
63
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
64
|
+
private joinTimeout: NodeJS.Timeout | null = null;
|
|
61
65
|
private identity: KichiIdentity | null = null;
|
|
62
66
|
private host: string | null = null;
|
|
63
67
|
private joinResolve: ((result: JoinResult) => void) | null = null;
|
|
@@ -81,7 +85,7 @@ export class KichiForwarderService {
|
|
|
81
85
|
this.identity = this.host ? this.loadIdentity() : null;
|
|
82
86
|
this.stopped = false;
|
|
83
87
|
if (this.host) {
|
|
84
|
-
this.connect();
|
|
88
|
+
this.connect("startup");
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
87
91
|
this.log("debug", "host is not configured yet; waiting for kichi_switch_host");
|
|
@@ -104,7 +108,7 @@ export class KichiForwarderService {
|
|
|
104
108
|
this.failPendingJoin(`Kichi websocket switched to ${host}`);
|
|
105
109
|
this.closeSocket();
|
|
106
110
|
if (!this.stopped) {
|
|
107
|
-
this.connect();
|
|
111
|
+
this.connect("switch_host");
|
|
108
112
|
}
|
|
109
113
|
return this.getConnectionStatus();
|
|
110
114
|
}
|
|
@@ -118,7 +122,14 @@ export class KichiForwarderService {
|
|
|
118
122
|
if (!this.host) {
|
|
119
123
|
return { success: false, error: "No Kichi host configured. Run kichi_switch_host first." };
|
|
120
124
|
}
|
|
125
|
+
if (this.ws?.readyState !== WebSocket.OPEN && this.ws?.readyState !== WebSocket.CONNECTING) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: "Kichi websocket is not connected. Restart the gateway to reconnect before joining.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
121
131
|
return new Promise((resolve) => {
|
|
132
|
+
this.failPendingJoin("Kichi join superseded by a new join request");
|
|
122
133
|
this.identity = { avatarId };
|
|
123
134
|
this.saveIdentity();
|
|
124
135
|
this.joinResolve = resolve;
|
|
@@ -129,9 +140,10 @@ export class KichiForwarderService {
|
|
|
129
140
|
} else {
|
|
130
141
|
this.ws?.once("open", sendJoin);
|
|
131
142
|
}
|
|
132
|
-
setTimeout(() => {
|
|
143
|
+
this.joinTimeout = setTimeout(() => {
|
|
133
144
|
if (this.joinResolve) {
|
|
134
145
|
this.joinResolve = null;
|
|
146
|
+
this.clearJoinTimeout();
|
|
135
147
|
resolve({ success: false, error: "Timed out waiting for join_ack" });
|
|
136
148
|
}
|
|
137
149
|
}, 10000);
|
|
@@ -153,6 +165,30 @@ export class KichiForwarderService {
|
|
|
153
165
|
this.ws.send(JSON.stringify(payload));
|
|
154
166
|
}
|
|
155
167
|
|
|
168
|
+
async sendStatusVerified(
|
|
169
|
+
poseType: PoseType | "",
|
|
170
|
+
action: string,
|
|
171
|
+
bubble: string,
|
|
172
|
+
log: string,
|
|
173
|
+
playback: ActionPlayback,
|
|
174
|
+
): Promise<StatusAckPayload> {
|
|
175
|
+
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) {
|
|
176
|
+
throw new Error("Kichi websocket is not connected");
|
|
177
|
+
}
|
|
178
|
+
const payload: StatusPayload = {
|
|
179
|
+
type: "status",
|
|
180
|
+
requestId: randomUUID(),
|
|
181
|
+
avatarId: this.identity.avatarId,
|
|
182
|
+
authKey: this.identity.authKey,
|
|
183
|
+
poseType,
|
|
184
|
+
action,
|
|
185
|
+
bubble,
|
|
186
|
+
log,
|
|
187
|
+
playback,
|
|
188
|
+
};
|
|
189
|
+
return this.sendRequest<StatusAckPayload>(payload, "status_ack", 5000);
|
|
190
|
+
}
|
|
191
|
+
|
|
156
192
|
sendHookNotify(hookType: HookNotifyType, bubble: string): void {
|
|
157
193
|
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
|
|
158
194
|
const payload: HookNotifyPayload = {
|
|
@@ -344,12 +380,18 @@ export class KichiForwarderService {
|
|
|
344
380
|
};
|
|
345
381
|
}
|
|
346
382
|
|
|
347
|
-
this.
|
|
348
|
-
|
|
383
|
+
if (this.reconnectTimeout) {
|
|
384
|
+
return {
|
|
385
|
+
accepted: true,
|
|
386
|
+
mode: "reconnecting",
|
|
387
|
+
message: "WebSocket reconnect is already scheduled. Rejoin will be sent automatically on open.",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
349
391
|
return {
|
|
350
|
-
accepted:
|
|
351
|
-
mode: "
|
|
352
|
-
message: "
|
|
392
|
+
accepted: false,
|
|
393
|
+
mode: "unavailable",
|
|
394
|
+
message: "WebSocket is not connected. Restart the gateway or wait for the scheduled reconnect.",
|
|
353
395
|
};
|
|
354
396
|
}
|
|
355
397
|
|
|
@@ -409,12 +451,18 @@ export class KichiForwarderService {
|
|
|
409
451
|
});
|
|
410
452
|
}
|
|
411
453
|
|
|
412
|
-
private connect(): void {
|
|
454
|
+
private connect(reason: ConnectReason): void {
|
|
413
455
|
if (this.stopped || !this.host) return;
|
|
456
|
+
if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
|
|
457
|
+
this.log("debug", `skipped websocket connect (${reason}) because socket is already ${this.getWebsocketState()}`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
414
460
|
|
|
461
|
+
this.clearReconnectTimeout();
|
|
415
462
|
const wsUrl = this.getWsUrl();
|
|
416
463
|
const ws = new WebSocket(wsUrl);
|
|
417
464
|
this.ws = ws;
|
|
465
|
+
this.log("debug", `opening websocket (${reason}) to ${wsUrl}`);
|
|
418
466
|
|
|
419
467
|
ws.on("open", () => {
|
|
420
468
|
if (this.ws !== ws) return;
|
|
@@ -431,15 +479,16 @@ export class KichiForwarderService {
|
|
|
431
479
|
if (this.ws !== ws) return;
|
|
432
480
|
this.ws = null;
|
|
433
481
|
this.rejectPendingRequests("Kichi websocket closed");
|
|
482
|
+
this.failPendingJoin("Kichi websocket closed");
|
|
434
483
|
if (!this.stopped) {
|
|
435
|
-
this.
|
|
436
|
-
this.reconnectTimeout = null;
|
|
437
|
-
this.connect();
|
|
438
|
-
}, 2000);
|
|
484
|
+
this.scheduleReconnect();
|
|
439
485
|
}
|
|
440
486
|
});
|
|
441
487
|
|
|
442
|
-
ws.on("error", () => {
|
|
488
|
+
ws.on("error", (error) => {
|
|
489
|
+
if (this.ws !== ws) return;
|
|
490
|
+
this.log("warn", `websocket error: ${error instanceof Error ? error.message : String(error)}`);
|
|
491
|
+
});
|
|
443
492
|
}
|
|
444
493
|
|
|
445
494
|
private handleMessage(data: string): void {
|
|
@@ -454,6 +503,7 @@ export class KichiForwarderService {
|
|
|
454
503
|
this.log("warn", `join failed: ${failure.error}`);
|
|
455
504
|
this.joinResolve?.(failure);
|
|
456
505
|
this.joinResolve = null;
|
|
506
|
+
this.clearJoinTimeout();
|
|
457
507
|
return;
|
|
458
508
|
}
|
|
459
509
|
|
|
@@ -464,6 +514,7 @@ export class KichiForwarderService {
|
|
|
464
514
|
}
|
|
465
515
|
this.joinResolve?.({ success: true, authKey: joinAck.authKey });
|
|
466
516
|
this.joinResolve = null;
|
|
517
|
+
this.clearJoinTimeout();
|
|
467
518
|
} else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
|
|
468
519
|
this.log("warn", `auth failed: ${msg.reason || "unknown"}`);
|
|
469
520
|
this.clearAuthKey();
|
|
@@ -712,6 +763,22 @@ export class KichiForwarderService {
|
|
|
712
763
|
this.reconnectTimeout = null;
|
|
713
764
|
}
|
|
714
765
|
|
|
766
|
+
private clearJoinTimeout(): void {
|
|
767
|
+
if (!this.joinTimeout) return;
|
|
768
|
+
clearTimeout(this.joinTimeout);
|
|
769
|
+
this.joinTimeout = null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private scheduleReconnect(): void {
|
|
773
|
+
if (this.reconnectTimeout || this.stopped) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
777
|
+
this.reconnectTimeout = null;
|
|
778
|
+
this.connect("reconnect");
|
|
779
|
+
}, 2000);
|
|
780
|
+
}
|
|
781
|
+
|
|
715
782
|
private closeSocket(): void {
|
|
716
783
|
const socket = this.ws;
|
|
717
784
|
this.ws = null;
|
|
@@ -723,6 +790,7 @@ export class KichiForwarderService {
|
|
|
723
790
|
if (!this.joinResolve) return;
|
|
724
791
|
this.joinResolve({ success: false, error: reason });
|
|
725
792
|
this.joinResolve = null;
|
|
793
|
+
this.clearJoinTimeout();
|
|
726
794
|
}
|
|
727
795
|
|
|
728
796
|
private logPrefix(): string {
|
package/src/types.ts
CHANGED
|
@@ -104,6 +104,7 @@ export type LeavePayload = {
|
|
|
104
104
|
|
|
105
105
|
export type StatusPayload = {
|
|
106
106
|
type: "status";
|
|
107
|
+
requestId?: string;
|
|
107
108
|
avatarId: string;
|
|
108
109
|
authKey: string;
|
|
109
110
|
poseType: PoseType | "";
|
|
@@ -113,6 +114,14 @@ export type StatusPayload = {
|
|
|
113
114
|
playback: ActionPlayback;
|
|
114
115
|
};
|
|
115
116
|
|
|
117
|
+
export type StatusAckPayload = {
|
|
118
|
+
type: "status_ack";
|
|
119
|
+
requestId: string;
|
|
120
|
+
poseType: PoseType | "";
|
|
121
|
+
action: string;
|
|
122
|
+
warning?: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
116
125
|
export type HookNotifyType = "message_received" | "before_send_message";
|
|
117
126
|
|
|
118
127
|
export type HookNotifyPayload = {
|