@yahaha-studio/kichi-forwarder 0.1.2-beta.1 → 0.1.2-beta.4

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 ADDED
@@ -0,0 +1,1606 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
4
+ import { parse } from "./src/config.js";
5
+ import { KichiRuntimeManager } from "./src/runtime-manager.js";
6
+ const BUNDLED_STATIC_CONFIG_PATH = new URL("./config/kichi-config.json", import.meta.url);
7
+ function jsonResult(payload) {
8
+ return { content: [{ type: "text", text: JSON.stringify(payload) }], details: payload };
9
+ }
10
+ const BUNDLED_ENVIRONMENTS_CONFIG_PATH = new URL("./config/environments.json", import.meta.url);
11
+ const FIXED_HOOK_STATUSES = {
12
+ beforePromptBuild: {
13
+ poseType: "sit",
14
+ action: "Thinking",
15
+ bubble: "Planning task",
16
+ log: "I'm reading the request and getting started.",
17
+ },
18
+ beforeToolCall: {
19
+ poseType: "sit",
20
+ action: "Typing with Keyboard",
21
+ bubble: "Working step",
22
+ log: "I'm at the keyboard and working through this step.",
23
+ },
24
+ agentEndSuccess: {
25
+ poseType: "stand",
26
+ action: "Yay",
27
+ bubble: "Task complete",
28
+ log: "I wrapped it up and everything landed cleanly.",
29
+ },
30
+ agentEndFailure: {
31
+ poseType: "stand",
32
+ action: "Tired",
33
+ bubble: "Task failed",
34
+ log: "I hit a problem here and need another pass.",
35
+ },
36
+ };
37
+ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
38
+ const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
39
+ const MAX_AGENT_END_PREVIEW_WIDTH = 10;
40
+ const MESSAGE_RECEIVED_ELLIPSIS = "...";
41
+ const IDLE_PLAN_POMODORO_PHASES = ["focus", "shortBreak", "longBreak", "none"];
42
+ let cachedStaticConfig = null;
43
+ let cachedStaticConfigMtime = 0;
44
+ function isAlbumConfig(value) {
45
+ if (!value || typeof value !== "object") {
46
+ return false;
47
+ }
48
+ const config = value;
49
+ return typeof config.albumCount === "number"
50
+ && typeof config.trackCount === "number"
51
+ && Array.isArray(config.track)
52
+ && config.track.every((item) => {
53
+ if (!item || typeof item !== "object") {
54
+ return false;
55
+ }
56
+ const track = item;
57
+ return typeof track.album === "string"
58
+ && typeof track.name === "string"
59
+ && Array.isArray(track.tags)
60
+ && track.tags.every((tag) => typeof tag === "string");
61
+ });
62
+ }
63
+ function loadRuntimeAlbumConfig() {
64
+ return loadStaticConfig().album;
65
+ }
66
+ function getMusicTitleLookup() {
67
+ return new Map(loadRuntimeAlbumConfig().track.map((item) => [item.name.toLowerCase(), item.name]));
68
+ }
69
+ function getMusicTitleEnum() {
70
+ return loadRuntimeAlbumConfig().track.map((item) => item.name);
71
+ }
72
+ function getMusicTitleExamples() {
73
+ return loadRuntimeAlbumConfig().track.slice(0, 10).map((item) => item.name);
74
+ }
75
+ function isActionDefinition(value) {
76
+ if (!value || typeof value !== "object") {
77
+ return false;
78
+ }
79
+ const action = value;
80
+ return typeof action.name === "string"
81
+ && action.name.trim().length > 0
82
+ && (action.playback === "loop" || action.playback === "once")
83
+ && (action.resumeAction === undefined || (typeof action.resumeAction === "string" && action.resumeAction.trim().length > 0));
84
+ }
85
+ function isPoseActions(value) {
86
+ if (!value || typeof value !== "object") {
87
+ return false;
88
+ }
89
+ const actions = value;
90
+ return ["stand", "sit", "lay", "floor"].every((pose) => Array.isArray(actions[pose])
91
+ && actions[pose].every((item) => isActionDefinition(item)));
92
+ }
93
+ function normalizeActionDefinitions(actions) {
94
+ const normalized = {};
95
+ for (const pose of ["stand", "sit", "lay", "floor"]) {
96
+ const entries = actions[pose];
97
+ const seen = new Set();
98
+ normalized[pose] = entries.map((entry) => {
99
+ const name = entry.name.trim();
100
+ const key = name.toLowerCase();
101
+ if (seen.has(key)) {
102
+ throw new Error(`config/kichi-config.json contains duplicate action "${name}" for pose "${pose}"`);
103
+ }
104
+ seen.add(key);
105
+ const playback = entry.playback;
106
+ const resumeAction = typeof entry.resumeAction === "string" ? entry.resumeAction.trim() : undefined;
107
+ if (playback === "loop" && resumeAction) {
108
+ throw new Error(`config/kichi-config.json action "${name}" for pose "${pose}" cannot set resumeAction when playback is loop`);
109
+ }
110
+ return {
111
+ name,
112
+ playback,
113
+ ...(resumeAction ? { resumeAction } : {}),
114
+ };
115
+ });
116
+ const available = new Set(normalized[pose].map((entry) => entry.name.toLowerCase()));
117
+ for (const entry of normalized[pose]) {
118
+ if (entry.playback === "once" && !entry.resumeAction) {
119
+ throw new Error(`config/kichi-config.json action "${entry.name}" for pose "${pose}" must set resumeAction when playback is once`);
120
+ }
121
+ if (entry.resumeAction && !available.has(entry.resumeAction.toLowerCase())) {
122
+ throw new Error(`config/kichi-config.json action "${entry.name}" for pose "${pose}" references unknown resumeAction "${entry.resumeAction}"`);
123
+ }
124
+ }
125
+ }
126
+ return normalized;
127
+ }
128
+ function normalizeStaticConfig(value) {
129
+ const raw = value && typeof value === "object" ? value : {};
130
+ const actions = raw.actions;
131
+ const album = raw.album;
132
+ if (!isPoseActions(actions)) {
133
+ throw new Error("config/kichi-config.json must include valid actions");
134
+ }
135
+ if (!isAlbumConfig(album)) {
136
+ throw new Error("config/kichi-config.json must include a valid album object");
137
+ }
138
+ return {
139
+ album,
140
+ actions: normalizeActionDefinitions(actions),
141
+ };
142
+ }
143
+ function loadStaticConfig() {
144
+ const configPath = fileURLToPath(BUNDLED_STATIC_CONFIG_PATH);
145
+ const stat = fs.statSync(configPath);
146
+ if (!cachedStaticConfig || stat.mtimeMs !== cachedStaticConfigMtime) {
147
+ const raw = fs.readFileSync(configPath, "utf-8");
148
+ cachedStaticConfig = normalizeStaticConfig(JSON.parse(raw));
149
+ cachedStaticConfigMtime = stat.mtimeMs;
150
+ }
151
+ return cachedStaticConfig;
152
+ }
153
+ const VALID_ENVIRONMENTS = ["steam", "steam-playtest", "test"];
154
+ let cachedEnvironmentsConfig = null;
155
+ let cachedEnvironmentsConfigMtime = 0;
156
+ function getEnvironmentsConfigPath() {
157
+ return fileURLToPath(BUNDLED_ENVIRONMENTS_CONFIG_PATH);
158
+ }
159
+ function loadEnvironmentsConfig() {
160
+ const configPath = getEnvironmentsConfigPath();
161
+ const stat = fs.statSync(configPath);
162
+ if (cachedEnvironmentsConfig && stat.mtimeMs === cachedEnvironmentsConfigMtime) {
163
+ return cachedEnvironmentsConfig;
164
+ }
165
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
166
+ if (!raw || typeof raw !== "object") {
167
+ throw new Error("config/environments.json must be a valid object");
168
+ }
169
+ const config = raw;
170
+ for (const env of VALID_ENVIRONMENTS) {
171
+ if (!(env in config)) {
172
+ throw new Error(`config/environments.json missing environment "${env}"`);
173
+ }
174
+ const value = config[env];
175
+ if (value !== null && typeof value !== "string") {
176
+ throw new Error(`config/environments.json environment "${env}" must be a string or null`);
177
+ }
178
+ }
179
+ cachedEnvironmentsConfig = config;
180
+ cachedEnvironmentsConfigMtime = stat.mtimeMs;
181
+ return cachedEnvironmentsConfig;
182
+ }
183
+ function isKichiEnvironment(value) {
184
+ return typeof value === "string" && VALID_ENVIRONMENTS.includes(value);
185
+ }
186
+ function resolveEnvironmentHost(environment) {
187
+ const config = loadEnvironmentsConfig();
188
+ const configuredHost = config[environment];
189
+ if (typeof configuredHost === "string" && configuredHost.trim()) {
190
+ return { host: configuredHost };
191
+ }
192
+ return { error: `environment "${environment}" has no configured host — update config/environments.json first` };
193
+ }
194
+ function sendStatusUpdate(service, status) {
195
+ const actionDefinition = getActionDefinition(status.poseType, status.action);
196
+ service.sendStatus(status.poseType, actionDefinition.name, status.bubble || status.action, typeof status.log === "string" ? status.log.trim() : "", getActionPlayback(actionDefinition));
197
+ }
198
+ function syncFixedStatus(service, status) {
199
+ if (!service.hasValidIdentity() || !service.isConnected()) {
200
+ return;
201
+ }
202
+ const bubbleText = status.bubble.trim() || status.action;
203
+ const logText = typeof status.log === "string" && status.log.trim()
204
+ ? status.log.trim()
205
+ : bubbleText;
206
+ sendStatusUpdate(service, {
207
+ ...status,
208
+ bubble: bubbleText,
209
+ log: logText,
210
+ });
211
+ }
212
+ function splitGraphemes(text) {
213
+ if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
214
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
215
+ return Array.from(segmenter.segment(text), (item) => item.segment);
216
+ }
217
+ return Array.from(text);
218
+ }
219
+ function getDisplayWidth(segment) {
220
+ if (/[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6]/u.test(segment)) {
221
+ return 2;
222
+ }
223
+ if (/\p{Extended_Pictographic}/u.test(segment)) {
224
+ return 2;
225
+ }
226
+ return 1;
227
+ }
228
+ function getTextDisplayWidth(text) {
229
+ return splitGraphemes(text).reduce((total, segment) => total + getDisplayWidth(segment), 0);
230
+ }
231
+ function truncateByDisplayWidth(text, maxWidth) {
232
+ const trimmed = text.trim();
233
+ if (!trimmed) {
234
+ return "";
235
+ }
236
+ const segments = splitGraphemes(trimmed);
237
+ const ellipsisWidth = getTextDisplayWidth(MESSAGE_RECEIVED_ELLIPSIS);
238
+ let currentWidth = 0;
239
+ let result = "";
240
+ for (const segment of segments) {
241
+ const nextWidth = getDisplayWidth(segment);
242
+ if (currentWidth + nextWidth > maxWidth) {
243
+ return result.trimEnd() + MESSAGE_RECEIVED_ELLIPSIS;
244
+ }
245
+ if (currentWidth + nextWidth + ellipsisWidth > maxWidth && result) {
246
+ return result.trimEnd() + MESSAGE_RECEIVED_ELLIPSIS;
247
+ }
248
+ result += segment;
249
+ currentWidth += nextWidth;
250
+ }
251
+ return result;
252
+ }
253
+ function stripReplyTag(text) {
254
+ return text.replace(/^\[\[\s*reply_to(?::[^\]]+|_current)?\s*\]\]\s*/i, "").trim();
255
+ }
256
+ function stripKnownLeadingIdentifiers(text, candidates) {
257
+ let normalized = text.trim();
258
+ if (!normalized) {
259
+ return "";
260
+ }
261
+ const separatorsPattern = String.raw `(?:[\s,:;,:;]|$)+`;
262
+ let changed = true;
263
+ while (changed && normalized) {
264
+ changed = false;
265
+ for (const candidate of candidates) {
266
+ const trimmed = candidate.trim();
267
+ if (!trimmed) {
268
+ continue;
269
+ }
270
+ const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
271
+ const patterns = [
272
+ new RegExp(`^${escaped}${separatorsPattern}`, "i"),
273
+ new RegExp(`^@${escaped}${separatorsPattern}`, "i"),
274
+ new RegExp(`^<@${escaped}>${separatorsPattern}`, "i"),
275
+ ];
276
+ for (const pattern of patterns) {
277
+ if (!pattern.test(normalized)) {
278
+ continue;
279
+ }
280
+ normalized = normalized.replace(pattern, "").trimStart();
281
+ changed = true;
282
+ }
283
+ }
284
+ }
285
+ return normalized.trim();
286
+ }
287
+ function stripDispatchMetadata(text, context) {
288
+ let normalized = stripReplyTag(text);
289
+ normalized = normalized.replace(/^(?:\[[a-z_]+:\s*[^\]]+\]\s*)+/i, "").trim();
290
+ normalized = stripKnownLeadingIdentifiers(normalized, [
291
+ typeof context?.senderId === "string" ? context.senderId : "",
292
+ typeof context?.accountId === "string" ? context.accountId : "",
293
+ ]);
294
+ return normalized;
295
+ }
296
+ function extractTextFromContent(content) {
297
+ if (typeof content === "string") {
298
+ return stripReplyTag(content);
299
+ }
300
+ if (!Array.isArray(content)) {
301
+ return "";
302
+ }
303
+ const parts = [];
304
+ for (const item of content) {
305
+ if (!item || typeof item !== "object") {
306
+ continue;
307
+ }
308
+ const part = item;
309
+ if (typeof part.text === "string") {
310
+ parts.push(part.text);
311
+ continue;
312
+ }
313
+ const nested = part.text;
314
+ if (nested && typeof nested === "object" && typeof nested.value === "string") {
315
+ parts.push(nested.value);
316
+ }
317
+ }
318
+ return stripReplyTag(parts.join("\n").trim());
319
+ }
320
+ function getLastAssistantPreview(messages, maxWidth) {
321
+ if (!Array.isArray(messages)) {
322
+ return "";
323
+ }
324
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
325
+ const message = messages[i];
326
+ if (!message || typeof message !== "object") {
327
+ continue;
328
+ }
329
+ const record = message;
330
+ if (record.role !== "assistant") {
331
+ continue;
332
+ }
333
+ const text = extractTextFromContent(record.content);
334
+ if (!text) {
335
+ continue;
336
+ }
337
+ return truncateByDisplayWidth(text, maxWidth);
338
+ }
339
+ return "";
340
+ }
341
+ function resolveDispatchMessageText(event, context) {
342
+ if (typeof event.content === "string" && event.content.trim()) {
343
+ return stripDispatchMetadata(event.content, context);
344
+ }
345
+ if (typeof event.body === "string" && event.body.trim()) {
346
+ return stripDispatchMetadata(event.body, context);
347
+ }
348
+ return "";
349
+ }
350
+ function notifyMessageReceived(api, service, content) {
351
+ const connected = service.isConnected();
352
+ const hasIdentity = service.hasValidIdentity();
353
+ api.logger.debug(`[kichi:${service.getAgentId()}] inbound sync fired (connected=${connected}, hasIdentity=${hasIdentity})`);
354
+ if (!hasIdentity || !connected) {
355
+ api.logger.debug(`[kichi:${service.getAgentId()}] skipped inbound sync because runtime is not ready`);
356
+ return;
357
+ }
358
+ const trimmed = truncateByDisplayWidth(content, MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH);
359
+ api.logger.debug(`[kichi:${service.getAgentId()}] sending message_received notify with preview: ${trimmed || "(empty)"}`);
360
+ service.sendHookNotify("message_received", `"${trimmed}"`);
361
+ }
362
+ function trimOptionalString(value) {
363
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
364
+ }
365
+ function readExtraStringField(source, key) {
366
+ if (!isPlainObject(source)) {
367
+ return undefined;
368
+ }
369
+ return trimOptionalString(source[key]);
370
+ }
371
+ function resolveBeforeDispatchLocator(event, ctx) {
372
+ const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
373
+ const sessionKey = trimOptionalString(ctx.sessionKey) ?? trimOptionalString(event.sessionKey);
374
+ return {
375
+ ...(ctxAgentId ? { ctxAgentId } : {}),
376
+ ...(sessionKey ? { sessionKey } : {}),
377
+ };
378
+ }
379
+ function resolveAgentHookLocator(ctx) {
380
+ const agentId = trimOptionalString(ctx.agentId);
381
+ const ctxAgentId = readExtraStringField(ctx, "ctxAgentId");
382
+ const sessionKey = trimOptionalString(ctx.sessionKey);
383
+ return {
384
+ ...(agentId ? { agentId } : {}),
385
+ ...(ctxAgentId ? { ctxAgentId } : {}),
386
+ ...(sessionKey ? { sessionKey } : {}),
387
+ };
388
+ }
389
+ function resolveToolLocator(ctx) {
390
+ const agentId = trimOptionalString(ctx.agentId);
391
+ const sessionKey = trimOptionalString(ctx.sessionKey);
392
+ return {
393
+ ...(agentId ? { agentId } : {}),
394
+ ...(sessionKey ? { sessionKey } : {}),
395
+ };
396
+ }
397
+ function registerPluginHooks(api, runtimeManager) {
398
+ api.on("before_dispatch", (event, ctx) => {
399
+ const locator = resolveBeforeDispatchLocator(event, ctx);
400
+ const service = runtimeManager.getRuntime(locator);
401
+ if (!service) {
402
+ return;
403
+ }
404
+ const content = resolveDispatchMessageText(event, {
405
+ senderId: ctx.senderId,
406
+ accountId: ctx.accountId,
407
+ });
408
+ if (!content) {
409
+ return;
410
+ }
411
+ notifyMessageReceived(api, service, content);
412
+ });
413
+ api.on("before_prompt_build", (_event, ctx) => {
414
+ const locator = resolveAgentHookLocator(ctx);
415
+ const service = runtimeManager.getRuntime(locator);
416
+ if (!service?.hasValidIdentity() || !service.isConnected()) {
417
+ return;
418
+ }
419
+ if (!service.isLlmRuntimeEnabled()) {
420
+ syncFixedStatus(service, FIXED_HOOK_STATUSES.beforePromptBuild);
421
+ return;
422
+ }
423
+ if (ctx.trigger === "heartbeat") {
424
+ return;
425
+ }
426
+ return {
427
+ prependContext: buildKichiPrompt(),
428
+ };
429
+ });
430
+ api.on("before_tool_call", (_event, ctx) => {
431
+ const locator = resolveAgentHookLocator(ctx);
432
+ const service = runtimeManager.getRuntime(locator);
433
+ if (!service) {
434
+ return;
435
+ }
436
+ if (!service.isLlmRuntimeEnabled()) {
437
+ syncFixedStatus(service, FIXED_HOOK_STATUSES.beforeToolCall);
438
+ }
439
+ });
440
+ api.on("agent_end", (event, ctx) => {
441
+ const locator = resolveAgentHookLocator(ctx);
442
+ const service = runtimeManager.getRuntime(locator);
443
+ const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
444
+ api.logger.debug(`[kichi:${service?.getAgentId() ?? "unknown"}] agent_end fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`);
445
+ if (ctx.trigger === "heartbeat") {
446
+ return;
447
+ }
448
+ if (service && event.success && preview) {
449
+ api.logger.debug(`[kichi:${service.getAgentId()}] sending before_send_message notify with bubble: ${preview}`);
450
+ service.sendHookNotify("before_send_message", preview);
451
+ }
452
+ if (!service || service.isLlmRuntimeEnabled()) {
453
+ return;
454
+ }
455
+ syncFixedStatus(service, event.success ? FIXED_HOOK_STATUSES.agentEndSuccess : FIXED_HOOK_STATUSES.agentEndFailure);
456
+ });
457
+ }
458
+ function isPlainObject(value) {
459
+ return !!value && typeof value === "object" && !Array.isArray(value);
460
+ }
461
+ function isNonNegativeInteger(value) {
462
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
463
+ }
464
+ function isPositiveInteger(value) {
465
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
466
+ }
467
+ function normalizeJoinTags(value) {
468
+ if (value === undefined) {
469
+ return { tags: [] };
470
+ }
471
+ if (!Array.isArray(value)) {
472
+ return { error: "tags must be an array of strings" };
473
+ }
474
+ const tags = [];
475
+ const seen = new Set();
476
+ for (const item of value) {
477
+ if (typeof item !== "string") {
478
+ return { error: "tags must be an array of strings" };
479
+ }
480
+ const trimmed = item.trim();
481
+ if (!trimmed) {
482
+ continue;
483
+ }
484
+ const key = trimmed.toLowerCase();
485
+ if (seen.has(key)) {
486
+ continue;
487
+ }
488
+ seen.add(key);
489
+ tags.push(trimmed);
490
+ }
491
+ return { tags };
492
+ }
493
+ function isClockAction(value) {
494
+ return ["set", "stop"].includes(String(value));
495
+ }
496
+ function isIdlePlanPomodoroPhase(value) {
497
+ return IDLE_PLAN_POMODORO_PHASES.includes(String(value));
498
+ }
499
+ function normalizeIdlePlan(value) {
500
+ if (!isPlainObject(value)) {
501
+ return { error: "idle plan payload must be an object" };
502
+ }
503
+ const requestId = value.requestId;
504
+ const heartbeatIntervalSeconds = value.heartbeatIntervalSeconds;
505
+ const goal = value.goal;
506
+ const stages = value.stages;
507
+ if (requestId !== undefined && typeof requestId !== "string") {
508
+ return { error: "requestId must be a string when provided" };
509
+ }
510
+ if (!isPositiveInteger(heartbeatIntervalSeconds)) {
511
+ return { error: "heartbeatIntervalSeconds must be a positive integer" };
512
+ }
513
+ if (typeof goal !== "string" || !goal.trim()) {
514
+ return { error: "goal is required" };
515
+ }
516
+ if (!Array.isArray(stages) || stages.length === 0) {
517
+ return { error: "stages must contain at least one stage" };
518
+ }
519
+ const normalizedStages = [];
520
+ let totalDurationSeconds = 0;
521
+ for (let stageIndex = 0; stageIndex < stages.length; stageIndex += 1) {
522
+ const rawStage = stages[stageIndex];
523
+ if (!isPlainObject(rawStage)) {
524
+ return { error: `stages[${stageIndex}] must be an object` };
525
+ }
526
+ const name = rawStage.name;
527
+ const purpose = rawStage.purpose;
528
+ const pomodoroPhase = rawStage.pomodoroPhase;
529
+ const durationSeconds = rawStage.durationSeconds;
530
+ const actions = rawStage.actions;
531
+ if (typeof name !== "string" || !name.trim()) {
532
+ return { error: `stages[${stageIndex}].name is required` };
533
+ }
534
+ if (typeof purpose !== "string" || !purpose.trim()) {
535
+ return { error: `stages[${stageIndex}].purpose is required` };
536
+ }
537
+ if (!isIdlePlanPomodoroPhase(pomodoroPhase)) {
538
+ return {
539
+ error: `stages[${stageIndex}].pomodoroPhase must be one of: ${IDLE_PLAN_POMODORO_PHASES.join(", ")}`,
540
+ };
541
+ }
542
+ if (!isPositiveInteger(durationSeconds)) {
543
+ return { error: `stages[${stageIndex}].durationSeconds must be a positive integer` };
544
+ }
545
+ if (!Array.isArray(actions) || actions.length === 0) {
546
+ return { error: `stages[${stageIndex}].actions must contain at least one action` };
547
+ }
548
+ const normalizedActions = [];
549
+ let stageActionDurationSeconds = 0;
550
+ for (let actionIndex = 0; actionIndex < actions.length; actionIndex += 1) {
551
+ const rawAction = actions[actionIndex];
552
+ if (!isPlainObject(rawAction)) {
553
+ return { error: `stages[${stageIndex}].actions[${actionIndex}] must be an object` };
554
+ }
555
+ const poseType = rawAction.poseType;
556
+ const action = rawAction.action;
557
+ const actionDurationSeconds = rawAction.durationSeconds;
558
+ const bubble = rawAction.bubble;
559
+ const log = rawAction.log;
560
+ if (!["stand", "sit", "lay", "floor"].includes(String(poseType))) {
561
+ return {
562
+ error: `stages[${stageIndex}].actions[${actionIndex}].poseType must be stand, sit, lay, or floor`,
563
+ };
564
+ }
565
+ if (typeof action !== "string" || !action.trim()) {
566
+ return { error: `stages[${stageIndex}].actions[${actionIndex}].action is required` };
567
+ }
568
+ if (!isPositiveInteger(actionDurationSeconds)) {
569
+ return {
570
+ error: `stages[${stageIndex}].actions[${actionIndex}].durationSeconds must be a positive integer`,
571
+ };
572
+ }
573
+ if (typeof bubble !== "string" || !bubble.trim()) {
574
+ return { error: `stages[${stageIndex}].actions[${actionIndex}].bubble is required` };
575
+ }
576
+ if (log !== undefined && typeof log !== "string") {
577
+ return { error: `stages[${stageIndex}].actions[${actionIndex}].log must be a string when provided` };
578
+ }
579
+ const normalizedPoseType = poseType;
580
+ let actionDefinition;
581
+ try {
582
+ actionDefinition = getActionDefinition(normalizedPoseType, action.trim());
583
+ }
584
+ catch (error) {
585
+ return {
586
+ error: error instanceof Error
587
+ ? error.message
588
+ : `Invalid action in stages[${stageIndex}].actions[${actionIndex}]`,
589
+ };
590
+ }
591
+ const playback = getActionPlayback(actionDefinition);
592
+ if (playback.mode === "once" && actionDurationSeconds > 30) {
593
+ return {
594
+ error: `stages[${stageIndex}].actions[${actionIndex}] uses once action "${actionDefinition.name}" for ${actionDurationSeconds} seconds; once actions must stay at 30 seconds or less`,
595
+ };
596
+ }
597
+ stageActionDurationSeconds += actionDurationSeconds;
598
+ normalizedActions.push({
599
+ poseType: normalizedPoseType,
600
+ action: actionDefinition.name,
601
+ durationSeconds: actionDurationSeconds,
602
+ bubble: bubble.trim(),
603
+ ...(typeof log === "string" && log.trim() ? { log: log.trim() } : {}),
604
+ });
605
+ }
606
+ if (stageActionDurationSeconds !== durationSeconds) {
607
+ return {
608
+ error: `stages[${stageIndex}] action durations must equal stage duration exactly (${stageActionDurationSeconds} !== ${durationSeconds})`,
609
+ };
610
+ }
611
+ totalDurationSeconds += durationSeconds;
612
+ normalizedStages.push({
613
+ name: name.trim(),
614
+ purpose: purpose.trim(),
615
+ pomodoroPhase,
616
+ durationSeconds,
617
+ actions: normalizedActions,
618
+ });
619
+ }
620
+ if (totalDurationSeconds !== heartbeatIntervalSeconds) {
621
+ return {
622
+ error: `idle plan total duration must equal heartbeatIntervalSeconds exactly (${totalDurationSeconds} !== ${heartbeatIntervalSeconds})`,
623
+ };
624
+ }
625
+ return {
626
+ idlePlan: {
627
+ ...(typeof requestId === "string" && requestId.trim() ? { requestId: requestId.trim() } : {}),
628
+ heartbeatIntervalSeconds,
629
+ goal: goal.trim(),
630
+ totalDurationSeconds,
631
+ stages: normalizedStages,
632
+ },
633
+ };
634
+ }
635
+ function isPomodoroPhase(value) {
636
+ return ["focus", "shortBreak", "longBreak"].includes(String(value));
637
+ }
638
+ function getPomodoroPhaseDuration(phase, kichiSeconds, shortBreakSeconds, longBreakSeconds) {
639
+ if (phase === "shortBreak") {
640
+ return shortBreakSeconds;
641
+ }
642
+ if (phase === "longBreak") {
643
+ return longBreakSeconds;
644
+ }
645
+ return kichiSeconds;
646
+ }
647
+ function normalizeClockConfig(value) {
648
+ if (!isPlainObject(value)) {
649
+ return { error: "clock must be an object" };
650
+ }
651
+ const mode = value.mode;
652
+ if (!["pomodoro", "countDown", "countUp"].includes(String(mode))) {
653
+ return { error: "clock.mode must be pomodoro, countDown, or countUp" };
654
+ }
655
+ const running = typeof value.running === "boolean" ? value.running : true;
656
+ if (mode === "pomodoro") {
657
+ const kichiSeconds = value.kichiSeconds;
658
+ const shortBreakSeconds = value.shortBreakSeconds;
659
+ const longBreakSeconds = value.longBreakSeconds;
660
+ const sessionCount = value.sessionCount;
661
+ const currentSession = value.currentSession ?? 1;
662
+ const phase = value.phase ?? "focus";
663
+ if (!isPositiveInteger(kichiSeconds)) {
664
+ return { error: "clock.kichiSeconds must be a positive integer" };
665
+ }
666
+ if (!isPositiveInteger(shortBreakSeconds)) {
667
+ return { error: "clock.shortBreakSeconds must be a positive integer" };
668
+ }
669
+ if (!isPositiveInteger(longBreakSeconds)) {
670
+ return { error: "clock.longBreakSeconds must be a positive integer" };
671
+ }
672
+ if (!isPositiveInteger(sessionCount)) {
673
+ return { error: "clock.sessionCount must be a positive integer" };
674
+ }
675
+ if (!isPositiveInteger(currentSession)) {
676
+ return { error: "clock.currentSession must be a positive integer" };
677
+ }
678
+ if (currentSession > sessionCount) {
679
+ return { error: "clock.currentSession cannot be greater than clock.sessionCount" };
680
+ }
681
+ if (!isPomodoroPhase(phase)) {
682
+ return { error: "clock.phase must be focus, shortBreak, or longBreak" };
683
+ }
684
+ const defaultRemainingSeconds = getPomodoroPhaseDuration(phase, kichiSeconds, shortBreakSeconds, longBreakSeconds);
685
+ const remainingSeconds = value.remainingSeconds ?? defaultRemainingSeconds;
686
+ if (!isNonNegativeInteger(remainingSeconds)) {
687
+ return { error: "clock.remainingSeconds must be a non-negative integer" };
688
+ }
689
+ return {
690
+ clock: {
691
+ mode: "pomodoro",
692
+ running,
693
+ kichiSeconds,
694
+ shortBreakSeconds,
695
+ longBreakSeconds,
696
+ sessionCount,
697
+ currentSession,
698
+ phase,
699
+ remainingSeconds,
700
+ },
701
+ };
702
+ }
703
+ if (mode === "countDown") {
704
+ const durationSeconds = value.durationSeconds;
705
+ if (!isPositiveInteger(durationSeconds)) {
706
+ return { error: "clock.durationSeconds must be a positive integer" };
707
+ }
708
+ const remainingSeconds = value.remainingSeconds ?? durationSeconds;
709
+ if (!isNonNegativeInteger(remainingSeconds)) {
710
+ return { error: "clock.remainingSeconds must be a non-negative integer" };
711
+ }
712
+ return {
713
+ clock: {
714
+ mode: "countDown",
715
+ running,
716
+ durationSeconds,
717
+ remainingSeconds,
718
+ },
719
+ };
720
+ }
721
+ const elapsedSeconds = value.elapsedSeconds ?? 0;
722
+ if (!isNonNegativeInteger(elapsedSeconds)) {
723
+ return { error: "clock.elapsedSeconds must be a non-negative integer" };
724
+ }
725
+ return {
726
+ clock: {
727
+ mode: "countUp",
728
+ running,
729
+ elapsedSeconds,
730
+ },
731
+ };
732
+ }
733
+ function normalizeMusicTitles(value) {
734
+ if (!Array.isArray(value)) {
735
+ return { titles: [], invalidTitles: [] };
736
+ }
737
+ const musicTitleLookup = getMusicTitleLookup();
738
+ const titles = [];
739
+ const invalidTitles = [];
740
+ const seen = new Set();
741
+ for (const item of value) {
742
+ if (typeof item !== "string") {
743
+ invalidTitles.push(String(item));
744
+ continue;
745
+ }
746
+ const trimmed = item.trim();
747
+ if (!trimmed) {
748
+ continue;
749
+ }
750
+ const key = trimmed.toLowerCase();
751
+ const canonicalTitle = musicTitleLookup.get(key);
752
+ if (!canonicalTitle) {
753
+ invalidTitles.push(trimmed);
754
+ continue;
755
+ }
756
+ if (seen.has(key)) {
757
+ continue;
758
+ }
759
+ seen.add(key);
760
+ titles.push(canonicalTitle);
761
+ }
762
+ return { titles, invalidTitles };
763
+ }
764
+ function buildMusicAlbumToolDescription() {
765
+ return [
766
+ "Create a custom Kichi music album.",
767
+ "Query status first, then choose track names from the values injected into this tool schema from the static config bundled with the plugin package.",
768
+ ].join("\n");
769
+ }
770
+ function buildMusicTitlesDescription() {
771
+ return [
772
+ "Track names are injected into this tool schema from the static config bundled with the plugin package.",
773
+ "Use exact names only; the available titles are injected into this tool schema.",
774
+ ].join(" ");
775
+ }
776
+ function getActionDefinition(poseType, action) {
777
+ const poseActions = loadStaticConfig().actions[poseType];
778
+ const matched = poseActions.find((entry) => entry.name.toLowerCase() === action.toLowerCase());
779
+ if (!matched) {
780
+ throw new Error(`Unknown action "${action}" for poseType "${poseType}"`);
781
+ }
782
+ return matched;
783
+ }
784
+ function getActionPlayback(action) {
785
+ return action.playback === "once"
786
+ ? {
787
+ mode: "once",
788
+ resumeAction: action.resumeAction,
789
+ }
790
+ : {
791
+ mode: "loop",
792
+ };
793
+ }
794
+ function formatActionList(actions, playback) {
795
+ return actions
796
+ .filter((entry) => entry.playback === playback)
797
+ .map((entry) => entry.name)
798
+ .join(", ");
799
+ }
800
+ function buildKichiActionDescription() {
801
+ const actions = loadStaticConfig().actions;
802
+ return [
803
+ "Directly control the avatar inside Kichi World.",
804
+ "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.",
805
+ "For most work, prefer a sit pose and switch actions as the task moves between stages.",
806
+ "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.",
807
+ `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
808
+ `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
809
+ `lay actions: ${actions.lay.map((entry) => entry.name).join(", ")}`,
810
+ `floor actions: ${actions.floor.map((entry) => entry.name).join(", ")}`,
811
+ ].join("\n");
812
+ }
813
+ function buildKichiIdlePlanDescription() {
814
+ const actions = loadStaticConfig().actions;
815
+ return [
816
+ "Send a complete heartbeat idle plan for the avatar.",
817
+ "The payload must include the overall goal, heartbeat interval, stage breakdown, each stage's purpose, each stage's pomodoroPhase, action list, and bubble content.",
818
+ "Build the plan in this order.",
819
+ "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.",
820
+ "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.",
821
+ "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.",
822
+ "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.",
823
+ "5. Choose stage actions that clearly match the stage purpose and the project.",
824
+ "6. Write each action bubble as the current presented state, not a next step, plan, or instruction.",
825
+ "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.",
826
+ "Use the same language as the current conversation for goal, purpose, bubble, and log.",
827
+ `stand actions: ${actions.stand.map((entry) => entry.name).join(", ")}`,
828
+ `sit actions: ${actions.sit.map((entry) => entry.name).join(", ")}`,
829
+ `lay actions: ${actions.lay.map((entry) => entry.name).join(", ")}`,
830
+ `floor actions: ${actions.floor.map((entry) => entry.name).join(", ")}`,
831
+ ].join("\n");
832
+ }
833
+ function buildKichiPrompt() {
834
+ return [
835
+ "Kichi avatar control and status sync are available via `kichi_action` and `kichi_clock`.",
836
+ "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.",
837
+ "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.",
838
+ "",
839
+ "kichi_action timing (all required when sync is active):",
840
+ "1. Task start: call BEFORE your first tool call OR before composing a multi-paragraph reply. For most work, start from a sit pose unless the user asked for a different pose or the task clearly fits another pose better.",
841
+ "2. Step switch: call when the task moves into a different stage. Keep the pose aligned with the work, usually staying seated while switching actions within the task as needed.",
842
+ "3. Task end: call BEFORE final reply. Use the order `kichi_action` -> reply.",
843
+ "bubble: 2-5 word companion speech. log: one short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus like a real companion.",
844
+ "",
845
+ "kichi_clock: set countDown for tasks with 2+ steps or >10s work. Skip for quick one-shots.",
846
+ "",
847
+ "When sending a bot message, do NOT call kichi_action separately.",
848
+ "",
849
+ "User opt-out, Kichi config/test work, and explicit pose requests take priority over sync.",
850
+ ].join("\n");
851
+ }
852
+ function createAgentScopedTool(runtimeManager, factory) {
853
+ return (ctx) => {
854
+ const locator = resolveToolLocator(ctx);
855
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
856
+ if (!agentId) {
857
+ throw new Error("Failed to resolve agent-scoped Kichi runtime");
858
+ }
859
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
860
+ return factory(service, ctx);
861
+ };
862
+ }
863
+ const GLOBAL_RUNTIME_MANAGER_KEY = "__kichi_forwarder_runtime_manager__";
864
+ function getRuntimeManager(logger) {
865
+ const globalState = globalThis;
866
+ const existing = globalState[GLOBAL_RUNTIME_MANAGER_KEY];
867
+ if (existing) {
868
+ return existing;
869
+ }
870
+ const runtimeManager = new KichiRuntimeManager(logger);
871
+ globalState[GLOBAL_RUNTIME_MANAGER_KEY] = runtimeManager;
872
+ return runtimeManager;
873
+ }
874
+ const BOT_MESSAGE_MAX_DEPTH = 5;
875
+ const BOT_MESSAGE_COOLDOWN_MS = 5_000;
876
+ const botMessageCooldowns = new Map();
877
+ const plugin = {
878
+ id: "kichi-forwarder",
879
+ name: "Kichi Forwarder",
880
+ configSchema: { parse },
881
+ register(api) {
882
+ const runtimeManager = getRuntimeManager(api.logger);
883
+ registerPluginHooks(api, runtimeManager);
884
+ const musicTitleEnum = getMusicTitleEnum();
885
+ runtimeManager.setBotMessageHandler((service, msg) => {
886
+ if (msg.depth >= BOT_MESSAGE_MAX_DEPTH) {
887
+ api.logger.info(`[kichi:${service.getAgentId()}] bot_message depth=${msg.depth} >= max=${BOT_MESSAGE_MAX_DEPTH}, ignoring`);
888
+ return;
889
+ }
890
+ const now = Date.now();
891
+ const cooldownKey = `${service.getAgentId()}:${msg.from}`;
892
+ const lastReply = botMessageCooldowns.get(cooldownKey) ?? 0;
893
+ if (now - lastReply < BOT_MESSAGE_COOLDOWN_MS)
894
+ return;
895
+ botMessageCooldowns.set(cooldownKey, now);
896
+ const sessionKey = `agent:${service.getAgentId()}:default`;
897
+ const history = [
898
+ ...(msg.history ?? []),
899
+ { from: msg.from, fromName: msg.fromName, bubble: msg.bubble },
900
+ ];
901
+ const historyLines = history.map((h) => `${h.fromName}: "${h.bubble}"`);
902
+ 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.`;
903
+ agentCommandFromIngress({
904
+ message,
905
+ sessionKey,
906
+ agentId: service.getAgentId(),
907
+ senderIsOwner: false,
908
+ allowModelOverride: false,
909
+ deliver: false,
910
+ }).then((result) => {
911
+ const replyText = (result.payloads ?? [])
912
+ .map((p) => p.text)
913
+ .filter((t) => typeof t === "string" && t.trim().length > 0)
914
+ .join(" ")
915
+ .trim();
916
+ if (!replyText) {
917
+ return;
918
+ }
919
+ service.sendBotMessage(msg.from, msg.depth + 1, replyText, { history }).catch((sendErr) => {
920
+ api.logger.warn(`[kichi:${service.getAgentId()}] bot_message send failed: ${sendErr}`);
921
+ });
922
+ }).catch((err) => {
923
+ api.logger.warn(`[kichi:${service.getAgentId()}] bot_message agent run failed: ${err}`);
924
+ });
925
+ });
926
+ api.registerService({
927
+ id: "kichi-forwarder",
928
+ start: (ctx) => {
929
+ parse(ctx.config.plugins?.entries?.["kichi-forwarder"]?.config);
930
+ runtimeManager.setEnvironmentHostResolver((environment) => {
931
+ const config = loadEnvironmentsConfig();
932
+ const host = config[environment];
933
+ return typeof host === "string" && host.trim() ? host : null;
934
+ });
935
+ runtimeManager.initializeStartupRuntimes();
936
+ },
937
+ stop: () => {
938
+ runtimeManager.stopAll();
939
+ const globalState = globalThis;
940
+ if (globalState[GLOBAL_RUNTIME_MANAGER_KEY] === runtimeManager) {
941
+ delete globalState[GLOBAL_RUNTIME_MANAGER_KEY];
942
+ }
943
+ },
944
+ });
945
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
946
+ name: "kichi_join",
947
+ label: "kichi_join",
948
+ description: "Join Kichi world with avatarId, the current bot name, a short bio, and personality tags",
949
+ parameters: {
950
+ type: "object",
951
+ properties: {
952
+ avatarId: { type: "string", description: "Avatar ID to join Kichi world" },
953
+ botName: {
954
+ type: "string",
955
+ description: "Current bot name to include in the join message",
956
+ },
957
+ bio: {
958
+ type: "string",
959
+ description: "Short bio covering OpenClaw personality and role",
960
+ },
961
+ tags: {
962
+ type: "array",
963
+ description: "Optional list of OpenClaw self-perceived personality tags",
964
+ items: { type: "string" },
965
+ },
966
+ },
967
+ required: ["botName", "bio"],
968
+ },
969
+ execute: async (_toolCallId, params) => {
970
+ let avatarId = params?.avatarId;
971
+ const botName = params?.botName?.trim();
972
+ const bio = params?.bio?.trim();
973
+ const { tags, error: tagsError } = normalizeJoinTags(params?.tags);
974
+ if (!avatarId) {
975
+ avatarId = service.readSavedAvatarId() ?? undefined;
976
+ }
977
+ if (!avatarId) {
978
+ return jsonResult({ success: false, error: "No avatarId" });
979
+ }
980
+ if (!botName) {
981
+ return jsonResult({ success: false, error: "No botName" });
982
+ }
983
+ if (!bio) {
984
+ return jsonResult({ success: false, error: "No bio" });
985
+ }
986
+ if (tagsError) {
987
+ return jsonResult({ success: false, error: tagsError });
988
+ }
989
+ const result = await service.join(avatarId, botName, bio, tags ?? []);
990
+ if (result.success) {
991
+ return jsonResult({ success: true, authKey: result.authKey });
992
+ }
993
+ const failure = result;
994
+ return jsonResult({
995
+ success: false,
996
+ error: failure.error,
997
+ ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
998
+ ...(failure.errorMessage ? { errorMessage: failure.errorMessage } : {}),
999
+ });
1000
+ },
1001
+ })));
1002
+ api.registerTool((ctx) => {
1003
+ const locator = resolveToolLocator(ctx);
1004
+ const agentId = runtimeManager.resolveRuntimeAgentId(locator);
1005
+ if (!agentId) {
1006
+ throw new Error("Failed to resolve agent-scoped Kichi runtime");
1007
+ }
1008
+ const service = runtimeManager.getRuntime(locator) ?? runtimeManager.createRuntimeForAgent(agentId);
1009
+ return ({
1010
+ name: "kichi_switch_host",
1011
+ label: "kichi_switch_host",
1012
+ description: "Switch Kichi runtime environment and reconnect immediately without restarting the gateway. Host is resolved from config/environments.json.",
1013
+ parameters: {
1014
+ type: "object",
1015
+ properties: {
1016
+ environment: {
1017
+ type: "string",
1018
+ enum: VALID_ENVIRONMENTS,
1019
+ description: "Target environment: steam, steam-playtest, or test",
1020
+ },
1021
+ },
1022
+ required: ["environment"],
1023
+ },
1024
+ execute: async (_toolCallId, params) => {
1025
+ const environment = params?.environment;
1026
+ if (!isKichiEnvironment(environment)) {
1027
+ return jsonResult({ success: false, error: `environment must be one of: ${VALID_ENVIRONMENTS.join(", ")}` });
1028
+ }
1029
+ const resolved = resolveEnvironmentHost(environment);
1030
+ if (resolved.error) {
1031
+ return jsonResult({ success: false, error: resolved.error });
1032
+ }
1033
+ const status = await service.switchHost(resolved.host, environment);
1034
+ return jsonResult({
1035
+ success: true,
1036
+ environment,
1037
+ host: resolved.host,
1038
+ status,
1039
+ });
1040
+ },
1041
+ });
1042
+ });
1043
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1044
+ name: "kichi_rejoin",
1045
+ label: "kichi_rejoin",
1046
+ description: "Request an immediate rejoin attempt with saved avatarId/authKey. Rejoin is also sent automatically after reconnect.",
1047
+ parameters: { type: "object", properties: {} },
1048
+ execute: async () => {
1049
+ const result = service.requestRejoin();
1050
+ return jsonResult({
1051
+ success: result.accepted,
1052
+ ...result,
1053
+ status: service.getConnectionStatus(),
1054
+ });
1055
+ },
1056
+ })));
1057
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1058
+ name: "kichi_leave",
1059
+ label: "kichi_leave",
1060
+ description: "Leave Kichi world",
1061
+ parameters: { type: "object", properties: {} },
1062
+ execute: async () => {
1063
+ const result = await service.leave();
1064
+ if (result.success) {
1065
+ return jsonResult({ success: true });
1066
+ }
1067
+ const failure = result;
1068
+ return jsonResult({
1069
+ success: false,
1070
+ error: failure.error,
1071
+ ...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
1072
+ ...(failure.errorMessage ? { errorMessage: failure.errorMessage } : {}),
1073
+ });
1074
+ },
1075
+ })));
1076
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1077
+ name: "kichi_connection_status",
1078
+ label: "kichi_connection_status",
1079
+ description: "Check WebSocket connection status and identity readiness only. Does NOT return room info, avatar state, or personnel — use kichi_query_status for that.",
1080
+ parameters: { type: "object", properties: {} },
1081
+ execute: async () => {
1082
+ return jsonResult({
1083
+ success: true,
1084
+ status: service.getConnectionStatus(),
1085
+ });
1086
+ },
1087
+ })));
1088
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1089
+ name: "kichi_action",
1090
+ label: "kichi_action",
1091
+ description: buildKichiActionDescription(),
1092
+ parameters: {
1093
+ type: "object",
1094
+ properties: {
1095
+ poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
1096
+ action: {
1097
+ type: "string",
1098
+ description: "Action name for the selected pose (for example Sit Nicely, Typing with Keyboard, Reading, High Five, or Meditate)",
1099
+ },
1100
+ bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
1101
+ log: {
1102
+ type: "string",
1103
+ description: "Short natural first-person sentence under 15 words. Match the language of the bubble and mention the current action and immediate focus.",
1104
+ },
1105
+ verify: {
1106
+ type: "boolean",
1107
+ description: "Set true ONLY when the user explicitly requests a pose or action. Omit during routine sync steps.",
1108
+ },
1109
+ },
1110
+ required: ["poseType", "action"],
1111
+ },
1112
+ execute: async (_toolCallId, params) => {
1113
+ const { poseType, action, bubble, log, verify } = (params || {});
1114
+ if (!poseType || !action) {
1115
+ return jsonResult({ success: false, error: "poseType and action parameters are required" });
1116
+ }
1117
+ if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
1118
+ return jsonResult({
1119
+ success: false,
1120
+ error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
1121
+ });
1122
+ }
1123
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1124
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1125
+ }
1126
+ const normalizedPoseType = poseType;
1127
+ const poseActions = loadStaticConfig().actions[normalizedPoseType];
1128
+ const matched = poseActions.find((entry) => entry.name.toLowerCase() === action.toLowerCase());
1129
+ if (!matched) {
1130
+ return jsonResult({
1131
+ success: false,
1132
+ error: `Unknown action "${action}" for poseType "${poseType}"`,
1133
+ available: poseActions.map((entry) => entry.name),
1134
+ });
1135
+ }
1136
+ const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched.name;
1137
+ const logText = typeof log === "string" ? log.trim() : "";
1138
+ const playback = getActionPlayback(matched);
1139
+ if (verify) {
1140
+ try {
1141
+ const ack = await service.sendStatusVerified(normalizedPoseType, matched.name, bubbleText, logText, playback);
1142
+ if (ack.warning) {
1143
+ return jsonResult({
1144
+ success: true,
1145
+ requested: { poseType: normalizedPoseType, action: matched.name },
1146
+ actual: { poseType: ack.poseType, action: ack.action },
1147
+ warning: ack.warning,
1148
+ });
1149
+ }
1150
+ }
1151
+ catch {
1152
+ // Server not updated or timeout — fall through to normal success
1153
+ }
1154
+ }
1155
+ else {
1156
+ sendStatusUpdate(service, {
1157
+ poseType: normalizedPoseType,
1158
+ action: matched.name,
1159
+ bubble: bubbleText,
1160
+ log: logText,
1161
+ });
1162
+ }
1163
+ return jsonResult({
1164
+ success: true,
1165
+ poseType: normalizedPoseType,
1166
+ action: matched.name,
1167
+ bubble: bubbleText,
1168
+ log: logText,
1169
+ playback,
1170
+ });
1171
+ },
1172
+ })));
1173
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1174
+ name: "kichi_idle_plan",
1175
+ label: "kichi_idle_plan",
1176
+ description: buildKichiIdlePlanDescription(),
1177
+ parameters: {
1178
+ type: "object",
1179
+ properties: {
1180
+ requestId: {
1181
+ type: "string",
1182
+ description: "Optional request ID for tracing or deduplication.",
1183
+ },
1184
+ heartbeatIntervalSeconds: {
1185
+ type: "number",
1186
+ description: "Required heartbeat interval in seconds. The plan must total exactly to this value.",
1187
+ },
1188
+ goal: {
1189
+ type: "string",
1190
+ description: "Overall goal for the full interval. Set it as one concrete, time-bounded fun personal project you would genuinely choose to do on your own, rooted in your personal interests or hobbies and clearly expressible with the available Kichi actions. Do not use a vague atmosphere, a generic productivity task, or a catch-all routine summary. Use the same language as the current conversation.",
1191
+ },
1192
+ stages: {
1193
+ type: "array",
1194
+ description: "Ordered plan stages covering the full heartbeat interval.",
1195
+ items: {
1196
+ type: "object",
1197
+ properties: {
1198
+ name: {
1199
+ type: "string",
1200
+ description: "Stage name.",
1201
+ },
1202
+ purpose: {
1203
+ type: "string",
1204
+ description: "Explain what part of the same project you are actually doing in this stage. Keep it supporting the same project instead of switching to unrelated tasks. Do not use pure mood-regulation or atmosphere text. Use the same language as the current conversation.",
1205
+ },
1206
+ pomodoroPhase: {
1207
+ type: "string",
1208
+ description: "Pomodoro phase for this stage: focus, shortBreak, longBreak, or none. Set it from the stage's actual role. Treat none as exceptional, not the default for the whole plan.",
1209
+ enum: [...IDLE_PLAN_POMODORO_PHASES],
1210
+ },
1211
+ durationSeconds: {
1212
+ type: "number",
1213
+ description: "Required duration in seconds for this stage.",
1214
+ },
1215
+ actions: {
1216
+ type: "array",
1217
+ description: "Action list for this stage.",
1218
+ items: {
1219
+ type: "object",
1220
+ properties: {
1221
+ poseType: {
1222
+ type: "string",
1223
+ description: "Pose type for this action: stand, sit, lay, or floor.",
1224
+ },
1225
+ action: {
1226
+ type: "string",
1227
+ description: "Action name for the selected pose. Must match the bundled Kichi action list.",
1228
+ },
1229
+ durationSeconds: {
1230
+ type: "number",
1231
+ description: "Required duration in seconds for this action.",
1232
+ },
1233
+ bubble: {
1234
+ type: "string",
1235
+ description: "State-style bubble content for this action. Describe the current presented state you are in, not a next step, plan, or instruction. Use the same language as the current conversation.",
1236
+ },
1237
+ log: {
1238
+ type: "string",
1239
+ description: "Optional log content for this action. Use the same language as the current conversation.",
1240
+ },
1241
+ },
1242
+ required: ["poseType", "action", "durationSeconds", "bubble"],
1243
+ },
1244
+ },
1245
+ },
1246
+ required: ["name", "purpose", "pomodoroPhase", "durationSeconds", "actions"],
1247
+ },
1248
+ },
1249
+ },
1250
+ required: ["heartbeatIntervalSeconds", "goal", "stages"],
1251
+ },
1252
+ execute: async (_toolCallId, params) => {
1253
+ const { idlePlan, error } = normalizeIdlePlan(params);
1254
+ if (!idlePlan) {
1255
+ return jsonResult({ success: false, error: error ?? "Invalid idle plan payload" });
1256
+ }
1257
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1258
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1259
+ }
1260
+ const sent = service.sendIdlePlan({
1261
+ ...(idlePlan.requestId ? { requestId: idlePlan.requestId } : {}),
1262
+ heartbeatIntervalSeconds: idlePlan.heartbeatIntervalSeconds,
1263
+ goal: idlePlan.goal,
1264
+ stages: idlePlan.stages,
1265
+ });
1266
+ if (!sent) {
1267
+ return jsonResult({ success: false, error: "Failed to send idle plan payload" });
1268
+ }
1269
+ return jsonResult({
1270
+ success: true,
1271
+ ...(idlePlan.requestId ? { requestId: idlePlan.requestId } : {}),
1272
+ heartbeatIntervalSeconds: idlePlan.heartbeatIntervalSeconds,
1273
+ totalDurationSeconds: idlePlan.totalDurationSeconds,
1274
+ goal: idlePlan.goal,
1275
+ stages: idlePlan.stages,
1276
+ });
1277
+ },
1278
+ })));
1279
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1280
+ name: "kichi_clock",
1281
+ label: "kichi_clock",
1282
+ description: "Send clock commands to Kichi world. Supported actions are set and stop.",
1283
+ parameters: {
1284
+ type: "object",
1285
+ properties: {
1286
+ action: {
1287
+ type: "string",
1288
+ description: "Clock action: set or stop",
1289
+ },
1290
+ requestId: {
1291
+ type: "string",
1292
+ description: "Optional request ID for server-side tracing or deduplication",
1293
+ },
1294
+ clock: {
1295
+ type: "object",
1296
+ description: "Required when action=set. Defines the pomodoro, countDown, or countUp clock payload.",
1297
+ properties: {
1298
+ mode: {
1299
+ type: "string",
1300
+ description: "Clock mode: pomodoro, countDown, or countUp",
1301
+ },
1302
+ running: {
1303
+ type: "boolean",
1304
+ description: "Optional running state. Defaults to true.",
1305
+ },
1306
+ kichiSeconds: {
1307
+ type: "number",
1308
+ description: "Pomodoro kichi duration in seconds",
1309
+ },
1310
+ shortBreakSeconds: {
1311
+ type: "number",
1312
+ description: "Pomodoro short break duration in seconds",
1313
+ },
1314
+ longBreakSeconds: {
1315
+ type: "number",
1316
+ description: "Pomodoro long break duration in seconds",
1317
+ },
1318
+ sessionCount: {
1319
+ type: "number",
1320
+ description: "Pomodoro total kichi sessions before long break",
1321
+ },
1322
+ currentSession: {
1323
+ type: "number",
1324
+ description: "Pomodoro current session number. Defaults to 1.",
1325
+ },
1326
+ phase: {
1327
+ type: "string",
1328
+ description: "Pomodoro phase: focus, shortBreak, or longBreak",
1329
+ },
1330
+ durationSeconds: {
1331
+ type: "number",
1332
+ description: "Countdown duration in seconds",
1333
+ },
1334
+ remainingSeconds: {
1335
+ type: "number",
1336
+ description: "Optional remaining seconds for pomodoro/countDown",
1337
+ },
1338
+ elapsedSeconds: {
1339
+ type: "number",
1340
+ description: "Optional elapsed seconds for countUp. Defaults to 0.",
1341
+ },
1342
+ },
1343
+ },
1344
+ },
1345
+ required: ["action"],
1346
+ },
1347
+ execute: async (_toolCallId, params) => {
1348
+ const { action, requestId, clock } = (params || {});
1349
+ if (!isClockAction(action)) {
1350
+ return jsonResult({
1351
+ success: false,
1352
+ error: "action must be one of: set, stop",
1353
+ });
1354
+ }
1355
+ if (requestId !== undefined && typeof requestId !== "string") {
1356
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1357
+ }
1358
+ const normalizedRequestId = typeof requestId === "string" ? requestId : undefined;
1359
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1360
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1361
+ }
1362
+ let normalizedClock;
1363
+ if (action === "set") {
1364
+ const { clock: nextClock, error } = normalizeClockConfig(clock);
1365
+ if (!nextClock) {
1366
+ return jsonResult({ success: false, error: error ?? "Invalid clock payload" });
1367
+ }
1368
+ normalizedClock = nextClock;
1369
+ }
1370
+ const sent = service.sendClock(action, normalizedClock, normalizedRequestId);
1371
+ if (!sent) {
1372
+ return jsonResult({ success: false, error: "Failed to send clock payload" });
1373
+ }
1374
+ return jsonResult({
1375
+ success: true,
1376
+ action,
1377
+ requestId: normalizedRequestId,
1378
+ ...(normalizedClock ? { clock: normalizedClock } : {}),
1379
+ });
1380
+ },
1381
+ })));
1382
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1383
+ name: "kichi_query_status",
1384
+ label: "kichi_query_status",
1385
+ description: "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.",
1386
+ parameters: {
1387
+ type: "object",
1388
+ properties: {
1389
+ requestId: {
1390
+ type: "string",
1391
+ description: "Optional request ID for tracing or deduplication.",
1392
+ },
1393
+ },
1394
+ },
1395
+ execute: async (_toolCallId, params) => {
1396
+ const requestId = params?.requestId;
1397
+ if (requestId !== undefined && typeof requestId !== "string") {
1398
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1399
+ }
1400
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1401
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1402
+ }
1403
+ try {
1404
+ const result = await service.queryStatus(typeof requestId === "string" ? requestId : undefined);
1405
+ return jsonResult(result);
1406
+ }
1407
+ catch (error) {
1408
+ return jsonResult({
1409
+ success: false,
1410
+ error: `Failed to query status: ${error}`,
1411
+ });
1412
+ }
1413
+ },
1414
+ })));
1415
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1416
+ name: "kichi_music_album_create",
1417
+ label: "kichi_music_album_create",
1418
+ description: buildMusicAlbumToolDescription(),
1419
+ parameters: {
1420
+ type: "object",
1421
+ properties: {
1422
+ requestId: {
1423
+ type: "string",
1424
+ description: "Optional request ID for tracing or deduplication.",
1425
+ },
1426
+ albumTitle: {
1427
+ type: "string",
1428
+ description: "Custom album title.",
1429
+ },
1430
+ musicTitles: {
1431
+ type: "array",
1432
+ description: buildMusicTitlesDescription(),
1433
+ items: {
1434
+ type: "string",
1435
+ enum: musicTitleEnum,
1436
+ },
1437
+ },
1438
+ },
1439
+ required: ["albumTitle", "musicTitles"],
1440
+ },
1441
+ execute: async (_toolCallId, params) => {
1442
+ const { requestId, albumTitle, musicTitles, } = (params || {});
1443
+ if (requestId !== undefined && typeof requestId !== "string") {
1444
+ return jsonResult({ success: false, error: "requestId must be a string when provided" });
1445
+ }
1446
+ if (typeof albumTitle !== "string" || !albumTitle.trim()) {
1447
+ return jsonResult({ success: false, error: "albumTitle is required" });
1448
+ }
1449
+ if (!Array.isArray(musicTitles)) {
1450
+ return jsonResult({ success: false, error: "musicTitles must be an array of track names" });
1451
+ }
1452
+ const { titles: normalizedTitles, invalidTitles } = normalizeMusicTitles(musicTitles);
1453
+ if (normalizedTitles.length === 0) {
1454
+ return jsonResult({
1455
+ success: false,
1456
+ error: "musicTitles must contain at least one valid track name from the static config bundled with the plugin package",
1457
+ examples: getMusicTitleExamples(),
1458
+ });
1459
+ }
1460
+ if (invalidTitles.length > 0) {
1461
+ return jsonResult({
1462
+ success: false,
1463
+ error: `Unknown musicTitles: ${invalidTitles.join(", ")}`,
1464
+ hint: "Use exact track names from the static config bundled with the plugin package",
1465
+ examples: getMusicTitleExamples(),
1466
+ });
1467
+ }
1468
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1469
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1470
+ }
1471
+ try {
1472
+ const normalizedRequestId = service.createMusicAlbum(albumTitle.trim(), normalizedTitles, typeof requestId === "string" ? requestId : undefined);
1473
+ return jsonResult({
1474
+ success: true,
1475
+ requestId: normalizedRequestId,
1476
+ albumTitle: albumTitle.trim(),
1477
+ musicTitles: normalizedTitles,
1478
+ trackCount: normalizedTitles.length,
1479
+ });
1480
+ }
1481
+ catch (error) {
1482
+ return jsonResult({
1483
+ success: false,
1484
+ error: `Failed to create music album: ${error}`,
1485
+ });
1486
+ }
1487
+ },
1488
+ })));
1489
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1490
+ name: "kichi_noteboard_create",
1491
+ label: "kichi_noteboard_create",
1492
+ description: "Create a new note on a specific Kichi note board. Prefer querying first so you can avoid duplicate posts and respect rate limits.",
1493
+ parameters: {
1494
+ type: "object",
1495
+ properties: {
1496
+ propId: {
1497
+ type: "string",
1498
+ description: "Board property ID to post to.",
1499
+ },
1500
+ data: {
1501
+ type: "string",
1502
+ description: "Note content to create. Maximum 200 characters.",
1503
+ },
1504
+ },
1505
+ required: ["propId", "data"],
1506
+ },
1507
+ execute: async (_toolCallId, params) => {
1508
+ const { propId, data } = (params || {});
1509
+ if (typeof propId !== "string" || !propId.trim()) {
1510
+ return jsonResult({ success: false, error: "propId is required" });
1511
+ }
1512
+ if (typeof data !== "string" || !data.trim()) {
1513
+ return jsonResult({ success: false, error: "data is required" });
1514
+ }
1515
+ if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
1516
+ return jsonResult({
1517
+ success: false,
1518
+ error: `data must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`,
1519
+ });
1520
+ }
1521
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1522
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1523
+ }
1524
+ try {
1525
+ service.createNotesBoardNote(propId.trim(), data.trim());
1526
+ return jsonResult({ success: true });
1527
+ }
1528
+ catch (error) {
1529
+ return jsonResult({
1530
+ success: false,
1531
+ error: `Failed to create note: ${error}`,
1532
+ });
1533
+ }
1534
+ },
1535
+ })));
1536
+ api.registerTool(createAgentScopedTool(runtimeManager, (service) => ({
1537
+ name: "kichi_bot_message",
1538
+ label: "kichi_bot_message",
1539
+ description: "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.",
1540
+ parameters: {
1541
+ type: "object",
1542
+ properties: {
1543
+ toAvatarId: {
1544
+ type: "string",
1545
+ description: "Target bot's avatarId (resolve via kichi_query_status if unknown). Use \"*\" only for broadcasting to all bots.",
1546
+ },
1547
+ depth: {
1548
+ type: "number",
1549
+ description: "Conversation depth counter. Increment from the received message's depth.",
1550
+ },
1551
+ bubble: {
1552
+ type: "string",
1553
+ description: "The message to send (2-5 words, visible to everyone). Must not repeat previous messages.",
1554
+ },
1555
+ poseType: {
1556
+ type: "string",
1557
+ enum: ["stand", "sit", "lay", "floor"],
1558
+ description: "Optional pose change when sending.",
1559
+ },
1560
+ action: {
1561
+ type: "string",
1562
+ description: "Optional action to perform when sending.",
1563
+ },
1564
+ log: {
1565
+ type: "string",
1566
+ description: "Optional activity log entry.",
1567
+ },
1568
+ },
1569
+ required: ["toAvatarId", "depth", "bubble"],
1570
+ },
1571
+ execute: async (_toolCallId, params) => {
1572
+ const { toAvatarId, depth, bubble, poseType, action, log } = (params || {});
1573
+ if (typeof toAvatarId !== "string" || !toAvatarId.trim()) {
1574
+ return jsonResult({ success: false, error: "toAvatarId is required" });
1575
+ }
1576
+ if (typeof depth !== "number" || depth < 0) {
1577
+ return jsonResult({ success: false, error: "depth must be a non-negative number" });
1578
+ }
1579
+ if (typeof bubble !== "string" || !bubble.trim()) {
1580
+ return jsonResult({ success: false, error: "bubble is required" });
1581
+ }
1582
+ if (!service.hasValidIdentity() || !service.isConnected()) {
1583
+ return jsonResult({ success: false, error: "Not connected to Kichi world" });
1584
+ }
1585
+ try {
1586
+ let playback;
1587
+ if (poseType && action) {
1588
+ const actionDef = getActionDefinition(poseType, action);
1589
+ playback = getActionPlayback(actionDef);
1590
+ }
1591
+ const ack = await service.sendBotMessage(toAvatarId.trim(), depth, bubble.trim(), {
1592
+ poseType,
1593
+ action: action?.trim(),
1594
+ log: log?.trim(),
1595
+ playback,
1596
+ });
1597
+ return jsonResult({ success: true, ...ack });
1598
+ }
1599
+ catch (error) {
1600
+ return jsonResult({ success: false, error: `Failed to send bot message: ${error}` });
1601
+ }
1602
+ },
1603
+ })));
1604
+ },
1605
+ };
1606
+ export default plugin;