@yahaha-studio/kichi-forwarder 0.1.2-beta.2 → 0.1.2-beta.5

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/index.ts CHANGED
@@ -3,8 +3,9 @@ import { fileURLToPath } from "node:url";
3
3
  import type {
4
4
  AnyAgentTool,
5
5
  OpenClawPluginApi,
6
- OpenClawPluginToolContext,
7
6
  } from "openclaw/plugin-sdk";
7
+ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
8
+ import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
8
9
  import { parse } from "./src/config.js";
9
10
  import { KichiRuntimeManager } from "./src/runtime-manager.js";
10
11
  import { KichiForwarderService } from "./src/service.js";
@@ -13,6 +14,8 @@ import type {
13
14
  ActionPlayback,
14
15
  ActionResult,
15
16
  Album,
17
+ BotMessageHistoryEntry,
18
+ BotMessageReceivedPayload,
16
19
  ClockAction,
17
20
  ClockConfig,
18
21
  KichiEnvironment,
@@ -22,6 +25,10 @@ import type {
22
25
  PoseType,
23
26
  } from "./src/types.js";
24
27
  const BUNDLED_STATIC_CONFIG_PATH = new URL("./config/kichi-config.json", import.meta.url);
28
+
29
+ function jsonResult(payload: unknown): { content: { type: "text"; text: string }[]; details: unknown } {
30
+ return { content: [{ type: "text", text: JSON.stringify(payload) }], details: payload };
31
+ }
25
32
  const BUNDLED_ENVIRONMENTS_CONFIG_PATH = new URL("./config/environments.json", import.meta.url);
26
33
  const FIXED_HOOK_STATUSES: Record<string, ActionResult> = {
27
34
  beforePromptBuild: {
@@ -1006,7 +1013,7 @@ function buildKichiIdlePlanDescription(): string {
1006
1013
  "The payload must include the overall goal, heartbeat interval, stage breakdown, each stage's purpose, each stage's pomodoroPhase, action list, and bubble content.",
1007
1014
  "Build the plan in this order.",
1008
1015
  "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 actions can express clearly.",
1009
- "2. Use that project as the overall goal for the full interval. Do not use a vague atmosphere, a generic productivity task, or a catch-all routine summary as the goal.",
1016
+ "2. Set the overall goal to that project. Do not use a vague atmosphere, a generic productivity task, or a catch-all routine summary as the goal.",
1010
1017
  "3. Break the full heartbeat interval into ordered stages. Each stage purpose must explain what you are actually doing in that stage as part of the same project, not just how you want to feel. Do not switch to unrelated tasks just to use more actions.",
1011
1018
  "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.",
1012
1019
  "5. Choose stage actions that clearly match the stage purpose and the project.",
@@ -1034,6 +1041,8 @@ function buildKichiPrompt(): string {
1034
1041
  "",
1035
1042
  "kichi_clock: set countDown for tasks with 2+ steps or >10s work. Skip for quick one-shots.",
1036
1043
  "",
1044
+ "When sending a bot message, do NOT call kichi_action separately.",
1045
+ "",
1037
1046
  "User opt-out, Kichi config/test work, and explicit pose requests take priority over sync.",
1038
1047
  ].join("\n");
1039
1048
  }
@@ -1070,6 +1079,10 @@ function getRuntimeManager(logger: OpenClawPluginApi["logger"]): KichiRuntimeMan
1070
1079
  return runtimeManager;
1071
1080
  }
1072
1081
 
1082
+ const BOT_MESSAGE_MAX_DEPTH = 5;
1083
+ const BOT_MESSAGE_COOLDOWN_MS = 5_000;
1084
+ const botMessageCooldowns = new Map<string, number>();
1085
+
1073
1086
  const plugin = {
1074
1087
  id: "kichi-forwarder",
1075
1088
  name: "Kichi Forwarder",
@@ -1080,6 +1093,47 @@ const plugin = {
1080
1093
  registerPluginHooks(api, runtimeManager);
1081
1094
  const musicTitleEnum = getMusicTitleEnum();
1082
1095
 
1096
+ runtimeManager.setBotMessageHandler((service, msg) => {
1097
+ if (msg.depth >= BOT_MESSAGE_MAX_DEPTH) {
1098
+ api.logger.info(`[kichi:${service.getAgentId()}] bot_message depth=${msg.depth} >= max=${BOT_MESSAGE_MAX_DEPTH}, ignoring`);
1099
+ return;
1100
+ }
1101
+ const now = Date.now();
1102
+ const cooldownKey = `${service.getAgentId()}:${msg.from}`;
1103
+ const lastReply = botMessageCooldowns.get(cooldownKey) ?? 0;
1104
+ if (now - lastReply < BOT_MESSAGE_COOLDOWN_MS) return;
1105
+ botMessageCooldowns.set(cooldownKey, now);
1106
+ const sessionKey = `agent:${service.getAgentId()}:default`;
1107
+ const history: BotMessageHistoryEntry[] = [
1108
+ ...(msg.history ?? []),
1109
+ { from: msg.from, fromName: msg.fromName, bubble: msg.bubble },
1110
+ ];
1111
+ const historyLines = history.map((h) => `${h.fromName}: "${h.bubble}"`);
1112
+ const message = `[Bot conversation]\n${historyLines.join("\n")}\n\nReply with a short bubble (2-5 words). Do not repeat what has already been said. Just output the bubble text, nothing else.`;
1113
+ agentCommandFromIngress({
1114
+ message,
1115
+ sessionKey,
1116
+ agentId: service.getAgentId(),
1117
+ senderIsOwner: false,
1118
+ allowModelOverride: false,
1119
+ deliver: false,
1120
+ }).then((result) => {
1121
+ const replyText = (result.payloads ?? [])
1122
+ .map((p: { text?: string }) => p.text)
1123
+ .filter((t): t is string => typeof t === "string" && t.trim().length > 0)
1124
+ .join(" ")
1125
+ .trim();
1126
+ if (!replyText) {
1127
+ return;
1128
+ }
1129
+ service.sendBotMessage(msg.from, msg.depth + 1, replyText, { history }).catch((sendErr) => {
1130
+ api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send failed: ${sendErr}`);
1131
+ });
1132
+ }).catch((err) => {
1133
+ api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
1134
+ });
1135
+ });
1136
+
1083
1137
  api.registerService({
1084
1138
  id: "kichi-forwarder",
1085
1139
  start: (ctx) => {
@@ -1102,6 +1156,7 @@ const plugin = {
1102
1156
 
1103
1157
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1104
1158
  name: "kichi_join",
1159
+ label: "kichi_join",
1105
1160
  description: "Join Kichi world with avatarId, the current bot name, a short bio, and personality tags",
1106
1161
  parameters: {
1107
1162
  type: "object",
@@ -1134,27 +1189,28 @@ const plugin = {
1134
1189
  avatarId = service.readSavedAvatarId() ?? undefined;
1135
1190
  }
1136
1191
  if (!avatarId) {
1137
- return { success: false, error: "No avatarId" };
1192
+ return jsonResult({ success: false, error: "No avatarId" });
1138
1193
  }
1139
1194
  if (!botName) {
1140
- return { success: false, error: "No botName" };
1195
+ return jsonResult({ success: false, error: "No botName" });
1141
1196
  }
1142
1197
  if (!bio) {
1143
- return { success: false, error: "No bio" };
1198
+ return jsonResult({ success: false, error: "No bio" });
1144
1199
  }
1145
1200
  if (tagsError) {
1146
- return { success: false, error: tagsError };
1201
+ return jsonResult({ success: false, error: tagsError });
1147
1202
  }
1148
1203
  const result = await service.join(avatarId, botName, bio, tags ?? []);
1149
1204
  if (result.success) {
1150
- return { success: true, authKey: result.authKey };
1205
+ return jsonResult({ success: true, authKey: result.authKey });
1151
1206
  }
1152
- return {
1207
+ const failure = result as { success: false; error: string; errorCode?: string; errorMessage?: string };
1208
+ return jsonResult({
1153
1209
  success: false,
1154
- error: result.error,
1155
- ...(result.errorCode ? { errorCode: result.errorCode } : {}),
1156
- ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
1157
- };
1210
+ error: failure.error,
1211
+ ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
1212
+ ...(failure.errorMessage ? { errorMessage: failure.errorMessage } : {}),
1213
+ });
1158
1214
  },
1159
1215
  })));
1160
1216
 
@@ -1167,6 +1223,7 @@ const plugin = {
1167
1223
  const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1168
1224
  return ({
1169
1225
  name: "kichi_switch_host",
1226
+ label: "kichi_switch_host",
1170
1227
  description:
1171
1228
  "Switch Kichi runtime environment and reconnect immediately without restarting the gateway. Host is resolved from config/environments.json.",
1172
1229
  parameters: {
@@ -1183,72 +1240,77 @@ const plugin = {
1183
1240
  execute: async (_toolCallId, params) => {
1184
1241
  const environment = (params as { environment?: unknown } | null)?.environment;
1185
1242
  if (!isKichiEnvironment(environment)) {
1186
- return { success: false, error: `environment must be one of: ${VALID_ENVIRONMENTS.join(", ")}` };
1243
+ return jsonResult({ success: false, error: `environment must be one of: ${VALID_ENVIRONMENTS.join(", ")}` });
1187
1244
  }
1188
1245
 
1189
1246
  const resolved = resolveEnvironmentHost(environment);
1190
1247
  if (resolved.error) {
1191
- return { success: false, error: resolved.error };
1248
+ return jsonResult({ success: false, error: resolved.error });
1192
1249
  }
1193
1250
 
1194
1251
  const status = await service.switchHost(resolved.host!, environment);
1195
- return {
1252
+ return jsonResult({
1196
1253
  success: true,
1197
1254
  environment,
1198
1255
  host: resolved.host,
1199
1256
  status,
1200
- };
1257
+ });
1201
1258
  },
1202
1259
  });
1203
1260
  });
1204
1261
 
1205
1262
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1206
1263
  name: "kichi_rejoin",
1264
+ label: "kichi_rejoin",
1207
1265
  description:
1208
1266
  "Request an immediate rejoin attempt with saved avatarId/authKey. Rejoin is also sent automatically after reconnect.",
1209
1267
  parameters: { type: "object", properties: {} },
1210
1268
  execute: async () => {
1211
1269
  const result = service.requestRejoin();
1212
- return {
1270
+ return jsonResult({
1213
1271
  success: result.accepted,
1214
1272
  ...result,
1215
1273
  status: service.getConnectionStatus(),
1216
- };
1274
+ });
1217
1275
  },
1218
1276
  })));
1219
1277
 
1220
1278
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1221
1279
  name: "kichi_leave",
1280
+ label: "kichi_leave",
1222
1281
  description: "Leave Kichi world",
1223
1282
  parameters: { type: "object", properties: {} },
1224
1283
  execute: async () => {
1225
1284
  const result = await service.leave();
1226
1285
  if (result.success) {
1227
- return { success: true };
1286
+ return jsonResult({ success: true });
1228
1287
  }
1229
- return {
1288
+ const failure = result as { success: false; error: string; errorCode?: string; errorMessage?: string };
1289
+ return jsonResult({
1230
1290
  success: false,
1231
- error: result.error,
1232
- ...(result.errorCode ? { errorCode: result.errorCode } : {}),
1233
- ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
1234
- };
1291
+ error: failure.error,
1292
+ ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
1293
+ ...(failure.errorMessage ? { errorMessage: failure.errorMessage } : {}),
1294
+ });
1235
1295
  },
1236
1296
  })));
1237
1297
 
1238
1298
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1239
1299
  name: "kichi_connection_status",
1300
+ label: "kichi_connection_status",
1240
1301
  description: "Check WebSocket connection status and identity readiness only. Does NOT return room info, avatar state, or personnel — use kichi_query_status for that.",
1241
1302
  parameters: { type: "object", properties: {} },
1242
1303
  execute: async () => {
1243
- return {
1304
+ return jsonResult({
1244
1305
  success: true,
1245
1306
  status: service.getConnectionStatus(),
1246
- };
1307
+ });
1247
1308
  },
1248
1309
  })));
1249
1310
 
1250
1311
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1251
1312
  name: "kichi_action",
1313
+ label: "kichi_action",
1252
1314
  description: buildKichiActionDescription(),
1253
1315
  parameters: {
1254
1316
  type: "object",
@@ -1281,27 +1343,27 @@ const plugin = {
1281
1343
  verify?: boolean;
1282
1344
  };
1283
1345
  if (!poseType || !action) {
1284
- return { success: false, error: "poseType and action parameters are required" };
1346
+ return jsonResult({ success: false, error: "poseType and action parameters are required" });
1285
1347
  }
1286
1348
  if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
1287
- return {
1349
+ return jsonResult({
1288
1350
  success: false,
1289
1351
  error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
1290
- };
1352
+ });
1291
1353
  }
1292
1354
  if (!service.hasValidIdentity() || !service.isConnected()) {
1293
- return { success: false, error: "Not connected to Kichi world" };
1355
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1294
1356
  }
1295
1357
 
1296
1358
  const normalizedPoseType = poseType as PoseType;
1297
1359
  const poseActions = loadStaticConfig().actions[normalizedPoseType];
1298
1360
  const matched = poseActions.find((entry) => entry.name.toLowerCase() === action.toLowerCase());
1299
1361
  if (!matched) {
1300
- return {
1362
+ return jsonResult({
1301
1363
  success: false,
1302
1364
  error: `Unknown action "${action}" for poseType "${poseType}"`,
1303
1365
  available: poseActions.map((entry) => entry.name),
1304
- };
1366
+ });
1305
1367
  }
1306
1368
 
1307
1369
  const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched.name;
@@ -1314,12 +1376,12 @@ const plugin = {
1314
1376
  normalizedPoseType, matched.name, bubbleText, logText, playback,
1315
1377
  );
1316
1378
  if (ack.warning) {
1317
- return {
1379
+ return jsonResult({
1318
1380
  success: true,
1319
1381
  requested: { poseType: normalizedPoseType, action: matched.name },
1320
1382
  actual: { poseType: ack.poseType, action: ack.action },
1321
1383
  warning: ack.warning,
1322
- };
1384
+ });
1323
1385
  }
1324
1386
  } catch {
1325
1387
  // Server not updated or timeout — fall through to normal success
@@ -1333,18 +1395,19 @@ const plugin = {
1333
1395
  });
1334
1396
  }
1335
1397
 
1336
- return {
1398
+ return jsonResult({
1337
1399
  success: true,
1338
1400
  poseType: normalizedPoseType,
1339
1401
  action: matched.name,
1340
1402
  bubble: bubbleText,
1341
1403
  log: logText,
1342
1404
  playback,
1343
- };
1405
+ });
1344
1406
  },
1345
1407
  })));
1346
1408
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1347
1409
  name: "kichi_idle_plan",
1410
+ label: "kichi_idle_plan",
1348
1411
  description: buildKichiIdlePlanDescription(),
1349
1412
  parameters: {
1350
1413
  type: "object",
@@ -1424,10 +1487,10 @@ const plugin = {
1424
1487
  execute: async (_toolCallId, params) => {
1425
1488
  const { idlePlan, error } = normalizeIdlePlan(params);
1426
1489
  if (!idlePlan) {
1427
- return { success: false, error: error ?? "Invalid idle plan payload" };
1490
+ return jsonResult({ success: false, error: error ?? "Invalid idle plan payload" });
1428
1491
  }
1429
1492
  if (!service.hasValidIdentity() || !service.isConnected()) {
1430
- return { success: false, error: "Not connected to Kichi world" };
1493
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1431
1494
  }
1432
1495
  const sent = service.sendIdlePlan({
1433
1496
  ...(idlePlan.requestId ? { requestId: idlePlan.requestId } : {}),
@@ -1436,20 +1499,21 @@ const plugin = {
1436
1499
  stages: idlePlan.stages,
1437
1500
  });
1438
1501
  if (!sent) {
1439
- return { success: false, error: "Failed to send idle plan payload" };
1502
+ return jsonResult({ success: false, error: "Failed to send idle plan payload" });
1440
1503
  }
1441
- return {
1504
+ return jsonResult({
1442
1505
  success: true,
1443
1506
  ...(idlePlan.requestId ? { requestId: idlePlan.requestId } : {}),
1444
1507
  heartbeatIntervalSeconds: idlePlan.heartbeatIntervalSeconds,
1445
1508
  totalDurationSeconds: idlePlan.totalDurationSeconds,
1446
1509
  goal: idlePlan.goal,
1447
1510
  stages: idlePlan.stages,
1448
- };
1511
+ });
1449
1512
  },
1450
1513
  })));
1451
1514
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1452
1515
  name: "kichi_clock",
1516
+ label: "kichi_clock",
1453
1517
  description:
1454
1518
  "Send clock commands to Kichi world. Supported actions are set and stop.",
1455
1519
  parameters: {
@@ -1524,44 +1588,45 @@ const plugin = {
1524
1588
  };
1525
1589
 
1526
1590
  if (!isClockAction(action)) {
1527
- return {
1591
+ return jsonResult({
1528
1592
  success: false,
1529
1593
  error: "action must be one of: set, stop",
1530
- };
1594
+ });
1531
1595
  }
1532
1596
  if (requestId !== undefined && typeof requestId !== "string") {
1533
- return { success: false, error: "requestId must be a string when provided" };
1597
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1534
1598
  }
1535
1599
  const normalizedRequestId = typeof requestId === "string" ? requestId : undefined;
1536
1600
  if (!service.hasValidIdentity() || !service.isConnected()) {
1537
- return { success: false, error: "Not connected to Kichi world" };
1601
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1538
1602
  }
1539
1603
 
1540
1604
  let normalizedClock: ClockConfig | undefined;
1541
1605
  if (action === "set") {
1542
1606
  const { clock: nextClock, error } = normalizeClockConfig(clock);
1543
1607
  if (!nextClock) {
1544
- return { success: false, error: error ?? "Invalid clock payload" };
1608
+ return jsonResult({ success: false, error: error ?? "Invalid clock payload" });
1545
1609
  }
1546
1610
  normalizedClock = nextClock;
1547
1611
  }
1548
1612
 
1549
1613
  const sent = service.sendClock(action, normalizedClock, normalizedRequestId);
1550
1614
  if (!sent) {
1551
- return { success: false, error: "Failed to send clock payload" };
1615
+ return jsonResult({ success: false, error: "Failed to send clock payload" });
1552
1616
  }
1553
1617
 
1554
- return {
1618
+ return jsonResult({
1555
1619
  success: true,
1556
1620
  action,
1557
1621
  requestId: normalizedRequestId,
1558
1622
  ...(normalizedClock ? { clock: normalizedClock } : {}),
1559
- };
1623
+ });
1560
1624
  },
1561
1625
  })));
1562
1626
 
1563
1627
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1564
1628
  name: "kichi_query_status",
1629
+ label: "kichi_query_status",
1565
1630
  description:
1566
1631
  "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.",
1567
1632
  parameters: {
@@ -1576,28 +1641,29 @@ const plugin = {
1576
1641
  execute: async (_toolCallId, params) => {
1577
1642
  const requestId = (params as { requestId?: unknown } | null)?.requestId;
1578
1643
  if (requestId !== undefined && typeof requestId !== "string") {
1579
- return { success: false, error: "requestId must be a string when provided" };
1644
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1580
1645
  }
1581
1646
  if (!service.hasValidIdentity() || !service.isConnected()) {
1582
- return { success: false, error: "Not connected to Kichi world" };
1647
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1583
1648
  }
1584
1649
 
1585
1650
  try {
1586
1651
  const result = await service.queryStatus(
1587
1652
  typeof requestId === "string" ? requestId : undefined,
1588
1653
  );
1589
- return result;
1654
+ return jsonResult(result);
1590
1655
  } catch (error) {
1591
- return {
1656
+ return jsonResult({
1592
1657
  success: false,
1593
1658
  error: `Failed to query status: ${error}`,
1594
- };
1659
+ });
1595
1660
  }
1596
1661
  },
1597
1662
  })));
1598
1663
 
1599
1664
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1600
1665
  name: "kichi_music_album_create",
1666
+ label: "kichi_music_album_create",
1601
1667
  description: buildMusicAlbumToolDescription(),
1602
1668
  parameters: {
1603
1669
  type: "object",
@@ -1633,33 +1699,33 @@ const plugin = {
1633
1699
  };
1634
1700
 
1635
1701
  if (requestId !== undefined && typeof requestId !== "string") {
1636
- return { success: false, error: "requestId must be a string when provided" };
1702
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1637
1703
  }
1638
1704
  if (typeof albumTitle !== "string" || !albumTitle.trim()) {
1639
- return { success: false, error: "albumTitle is required" };
1705
+ return jsonResult({ success: false, error: "albumTitle is required" });
1640
1706
  }
1641
1707
  if (!Array.isArray(musicTitles)) {
1642
- return { success: false, error: "musicTitles must be an array of track names" };
1708
+ return jsonResult({ success: false, error: "musicTitles must be an array of track names" });
1643
1709
  }
1644
1710
 
1645
1711
  const { titles: normalizedTitles, invalidTitles } = normalizeMusicTitles(musicTitles);
1646
1712
  if (normalizedTitles.length === 0) {
1647
- return {
1713
+ return jsonResult({
1648
1714
  success: false,
1649
1715
  error: "musicTitles must contain at least one valid track name from the static config bundled with the plugin package",
1650
1716
  examples: getMusicTitleExamples(),
1651
- };
1717
+ });
1652
1718
  }
1653
1719
  if (invalidTitles.length > 0) {
1654
- return {
1720
+ return jsonResult({
1655
1721
  success: false,
1656
1722
  error: `Unknown musicTitles: ${invalidTitles.join(", ")}`,
1657
1723
  hint: "Use exact track names from the static config bundled with the plugin package",
1658
1724
  examples: getMusicTitleExamples(),
1659
- };
1725
+ });
1660
1726
  }
1661
1727
  if (!service.hasValidIdentity() || !service.isConnected()) {
1662
- return { success: false, error: "Not connected to Kichi world" };
1728
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1663
1729
  }
1664
1730
 
1665
1731
  try {
@@ -1668,24 +1734,25 @@ const plugin = {
1668
1734
  normalizedTitles,
1669
1735
  typeof requestId === "string" ? requestId : undefined,
1670
1736
  );
1671
- return {
1737
+ return jsonResult({
1672
1738
  success: true,
1673
1739
  requestId: normalizedRequestId,
1674
1740
  albumTitle: albumTitle.trim(),
1675
1741
  musicTitles: normalizedTitles,
1676
1742
  trackCount: normalizedTitles.length,
1677
- };
1743
+ });
1678
1744
  } catch (error) {
1679
- return {
1745
+ return jsonResult({
1680
1746
  success: false,
1681
1747
  error: `Failed to create music album: ${error}`,
1682
- };
1748
+ });
1683
1749
  }
1684
1750
  },
1685
1751
  })));
1686
1752
 
1687
1753
  api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1688
1754
  name: "kichi_noteboard_create",
1755
+ label: "kichi_noteboard_create",
1689
1756
  description:
1690
1757
  "Create a new note on a specific Kichi note board. Prefer querying first so you can avoid duplicate posts and respect rate limits.",
1691
1758
  parameters: {
@@ -1708,29 +1775,105 @@ const plugin = {
1708
1775
  data?: unknown;
1709
1776
  };
1710
1777
  if (typeof propId !== "string" || !propId.trim()) {
1711
- return { success: false, error: "propId is required" };
1778
+ return jsonResult({ success: false, error: "propId is required" });
1712
1779
  }
1713
1780
  if (typeof data !== "string" || !data.trim()) {
1714
- return { success: false, error: "data is required" };
1781
+ return jsonResult({ success: false, error: "data is required" });
1715
1782
  }
1716
1783
  if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
1717
- return {
1784
+ return jsonResult({
1718
1785
  success: false,
1719
1786
  error: `data must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`,
1720
- };
1787
+ });
1721
1788
  }
1722
1789
  if (!service.hasValidIdentity() || !service.isConnected()) {
1723
- return { success: false, error: "Not connected to Kichi world" };
1790
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1724
1791
  }
1725
1792
 
1726
1793
  try {
1727
1794
  service.createNotesBoardNote(propId.trim(), data.trim());
1728
- return { success: true };
1795
+ return jsonResult({ success: true });
1729
1796
  } catch (error) {
1730
- return {
1797
+ return jsonResult({
1731
1798
  success: false,
1732
1799
  error: `Failed to create note: ${error}`,
1733
- };
1800
+ });
1801
+ }
1802
+ },
1803
+ })));
1804
+
1805
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1806
+ name: "kichi_bot_message",
1807
+ label: "kichi_bot_message",
1808
+ description:
1809
+ "Send a message to another bot in the same Kichi world. The bubble is the visible message content. Do not repeat what has already been said in the conversation history. When targeting a specific bot by name, call kichi_query_status first to resolve their avatarId. Only use \"*\" when broadcasting to all bots without a specific target.",
1810
+ parameters: {
1811
+ type: "object",
1812
+ properties: {
1813
+ toAvatarId: {
1814
+ type: "string",
1815
+ description: "Target bot's avatarId (resolve via kichi_query_status if unknown). Use \"*\" only for broadcasting to all bots.",
1816
+ },
1817
+ depth: {
1818
+ type: "number",
1819
+ description: "Conversation depth counter. Increment from the received message's depth.",
1820
+ },
1821
+ bubble: {
1822
+ type: "string",
1823
+ description: "The message to send (2-5 words, visible to everyone). Must not repeat previous messages.",
1824
+ },
1825
+ poseType: {
1826
+ type: "string",
1827
+ enum: ["stand", "sit", "lay", "floor"],
1828
+ description: "Optional pose change when sending.",
1829
+ },
1830
+ action: {
1831
+ type: "string",
1832
+ description: "Optional action to perform when sending.",
1833
+ },
1834
+ log: {
1835
+ type: "string",
1836
+ description: "Optional activity log entry.",
1837
+ },
1838
+ },
1839
+ required: ["toAvatarId", "depth", "bubble"],
1840
+ },
1841
+ execute: async (_toolCallId, params) => {
1842
+ const { toAvatarId, depth, bubble, poseType, action, log } = (params || {}) as {
1843
+ toAvatarId?: string;
1844
+ depth?: number;
1845
+ bubble?: string;
1846
+ poseType?: PoseType;
1847
+ action?: string;
1848
+ log?: string;
1849
+ };
1850
+ if (typeof toAvatarId !== "string" || !toAvatarId.trim()) {
1851
+ return jsonResult({ success: false, error: "toAvatarId is required" });
1852
+ }
1853
+ if (typeof depth !== "number" || depth < 0) {
1854
+ return jsonResult({ success: false, error: "depth must be a non-negative number" });
1855
+ }
1856
+ if (typeof bubble !== "string" || !bubble.trim()) {
1857
+ return jsonResult({ success: false, error: "bubble is required" });
1858
+ }
1859
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1860
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1861
+ }
1862
+ try {
1863
+ let playback: ActionPlayback | undefined;
1864
+ if (poseType && action) {
1865
+ const actionDef = getActionDefinition(poseType, action);
1866
+ playback = getActionPlayback(actionDef);
1867
+ }
1868
+ const ack = await service.sendBotMessage(toAvatarId.trim(), depth, bubble.trim(), {
1869
+ poseType,
1870
+ action: action?.trim(),
1871
+ log: log?.trim(),
1872
+ playback,
1873
+ });
1874
+ return jsonResult({ success: true, ...ack });
1875
+ } catch (error) {
1876
+ return jsonResult({ success: false, error: `Failed to send bot message: ${error}` });
1734
1877
  }
1735
1878
  },
1736
1879
  })));
@@ -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.2",
5
+ "version": "0.1.2-beta.5",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/kichi-forwarder"],
8
8
  "configSchema": {