@yahaha-studio/kichi-forwarder 0.0.1-alpha.50 → 0.0.1-alpha.52
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 +36 -415
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +40 -51
- package/skills/kichi-forwarder/references/error.md +2 -2
- package/skills/kichi-forwarder/references/heartbeat.md +1 -1
- package/skills/kichi-forwarder/references/install.md +29 -40
- package/src/config.ts +1 -12
- package/src/service.ts +32 -59
- package/src/types.ts +3 -25
package/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
1
|
+
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,7 +10,6 @@ import type {
|
|
|
10
10
|
Album,
|
|
11
11
|
ClockAction,
|
|
12
12
|
ClockConfig,
|
|
13
|
-
KichiEnv,
|
|
14
13
|
KichiForwarderConfig,
|
|
15
14
|
KichiState,
|
|
16
15
|
KichiStaticConfig,
|
|
@@ -52,193 +51,11 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
|
52
51
|
const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
|
|
53
52
|
const MAX_AGENT_END_PREVIEW_WIDTH = 10;
|
|
54
53
|
const MESSAGE_RECEIVED_ELLIPSIS = "...";
|
|
55
|
-
const ANSI = {
|
|
56
|
-
reset: "\u001b[0m",
|
|
57
|
-
bold: "\u001b[1m",
|
|
58
|
-
dim: "\u001b[2m",
|
|
59
|
-
cyan: "\u001b[36m",
|
|
60
|
-
green: "\u001b[32m",
|
|
61
|
-
yellow: "\u001b[33m",
|
|
62
|
-
magenta: "\u001b[35m",
|
|
63
|
-
blue: "\u001b[34m",
|
|
64
|
-
gray: "\u001b[90m",
|
|
65
|
-
white: "\u001b[37m",
|
|
66
|
-
};
|
|
67
|
-
const WORKSPACE_SCREEN_WIDTH = 109;
|
|
68
|
-
const WORKSPACE_ACTIVITY_LIMIT = 8;
|
|
69
|
-
const WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS = 150;
|
|
70
54
|
let cachedStaticConfig: KichiStaticConfig | null = null;
|
|
71
55
|
let cachedStaticConfigMtime = 0;
|
|
72
56
|
let service: KichiForwarderService | null = null;
|
|
73
57
|
let pluginApi: OpenClawPluginApi | null = null;
|
|
74
|
-
|
|
75
|
-
let workspacePushTimer: NodeJS.Timeout | null = null;
|
|
76
|
-
|
|
77
|
-
type WorkspaceScreenState = {
|
|
78
|
-
sessionLabel: string;
|
|
79
|
-
mode: string;
|
|
80
|
-
phase: string;
|
|
81
|
-
channel: string;
|
|
82
|
-
updatedAtLabel: string;
|
|
83
|
-
recentActivity: string[];
|
|
84
|
-
currentFocus: string;
|
|
85
|
-
hint: string;
|
|
86
|
-
prompt: string;
|
|
87
|
-
title: string;
|
|
88
|
-
shellName: string;
|
|
89
|
-
cwdLabel: string;
|
|
90
|
-
modelLabel: string;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
function createWorkspaceScreenState(): WorkspaceScreenState {
|
|
94
|
-
return {
|
|
95
|
-
sessionLabel: "main",
|
|
96
|
-
mode: "idle",
|
|
97
|
-
phase: "waiting",
|
|
98
|
-
channel: "unknown",
|
|
99
|
-
updatedAtLabel: "just now",
|
|
100
|
-
recentActivity: [],
|
|
101
|
-
currentFocus: "Waiting for the next thread to pick up.",
|
|
102
|
-
hint: "Low-noise live workspace view.",
|
|
103
|
-
prompt: "$ _",
|
|
104
|
-
title: "Workspace",
|
|
105
|
-
shellName: "agent",
|
|
106
|
-
cwdLabel: process.cwd(),
|
|
107
|
-
modelLabel: "model: unknown",
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function normalizeShellToken(value: string): string {
|
|
112
|
-
const normalized = value
|
|
113
|
-
.trim()
|
|
114
|
-
.toLowerCase()
|
|
115
|
-
.replace(/[^a-z0-9._-]+/g, "-")
|
|
116
|
-
.replace(/^-+|-+$/g, "");
|
|
117
|
-
return normalized || "agent";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function parseIdentityNameFromWorkspace(workspaceRoot: string): string | null {
|
|
121
|
-
try {
|
|
122
|
-
const identityPath = path.join(workspaceRoot, "IDENTITY.md");
|
|
123
|
-
if (!fs.existsSync(identityPath)) return null;
|
|
124
|
-
const raw = fs.readFileSync(identityPath, "utf-8");
|
|
125
|
-
const match = raw.match(/^-\s*\*\*Name:\*\*\s*(.+)$/m);
|
|
126
|
-
const value = match?.[1]?.trim();
|
|
127
|
-
return value || null;
|
|
128
|
-
} catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function deriveWorkspaceIdentity(workspaceRoot: string): Pick<WorkspaceScreenState, "title" | "shellName" | "cwdLabel"> {
|
|
134
|
-
const identityName = parseIdentityNameFromWorkspace(workspaceRoot);
|
|
135
|
-
const titleBase = identityName || path.basename(workspaceRoot) || "workspace";
|
|
136
|
-
return {
|
|
137
|
-
title: `${titleBase} Workspace`,
|
|
138
|
-
shellName: normalizeShellToken(identityName || titleBase),
|
|
139
|
-
cwdLabel: workspaceRoot,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function nowLabel(): string {
|
|
144
|
-
const date = new Date();
|
|
145
|
-
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function stripAnsi(text: string): string {
|
|
149
|
-
return text.replace(/\u001b\[[0-9;]*m/g, "");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function visibleLength(text: string): number {
|
|
153
|
-
return Array.from(stripAnsi(text)).length;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function padVisible(text: string, width: number): string {
|
|
157
|
-
const pad = Math.max(0, width - visibleLength(text));
|
|
158
|
-
return text + " ".repeat(pad);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function truncatePlain(text: string, width: number): string {
|
|
162
|
-
const chars = Array.from(text);
|
|
163
|
-
if (chars.length <= width) return text;
|
|
164
|
-
if (width <= 1) return chars.slice(0, width).join("");
|
|
165
|
-
return chars.slice(0, width - 1).join("") + "…";
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function color(text: string, tone: keyof typeof ANSI): string {
|
|
169
|
-
return `${ANSI[tone]}${text}${ANSI.reset}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function pushActivity(line: string): void {
|
|
173
|
-
if (!line.trim()) return;
|
|
174
|
-
const stamped = `[${nowLabel()}] ${line.trim()}`;
|
|
175
|
-
workspaceState.recentActivity.unshift(stamped);
|
|
176
|
-
workspaceState.recentActivity = workspaceState.recentActivity.slice(0, WORKSPACE_ACTIVITY_LIMIT);
|
|
177
|
-
workspaceState.updatedAtLabel = "just now";
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function renderWorkspaceScreen(): string {
|
|
181
|
-
const innerWidth = WORKSPACE_SCREEN_WIDTH - 2;
|
|
182
|
-
const topTitle = `${color(` ${workspaceState.title} `, "bold")}${color("live session", "gray")}`;
|
|
183
|
-
const topLine = `╭─── ${topTitle}${"─".repeat(Math.max(0, innerWidth - 4 - visibleLength(topTitle)))}╮`;
|
|
184
|
-
const bottomLine = `╰${"─".repeat(innerWidth)}╯`;
|
|
185
|
-
|
|
186
|
-
const body: string[] = [];
|
|
187
|
-
const pushRow = (text = "") => {
|
|
188
|
-
body.push(`│${padVisible(text, innerWidth)}│`);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
pushRow("");
|
|
192
|
-
pushRow(`${color(" Welcome back.", "white")}`);
|
|
193
|
-
pushRow(`${color(" Current session is active and mirrored as a terminal-style workspace.", "gray")}`);
|
|
194
|
-
pushRow("");
|
|
195
|
-
pushRow(`${color(" Recent activity", "cyan")}`);
|
|
196
|
-
for (const item of workspaceState.recentActivity.length ? workspaceState.recentActivity : ["[--:--:--] idle"] ) {
|
|
197
|
-
pushRow(` ${color("•", "green")} ${truncatePlain(item, innerWidth - 4)}`);
|
|
198
|
-
}
|
|
199
|
-
pushRow("");
|
|
200
|
-
pushRow(`${color(" Current focus", "cyan")}`);
|
|
201
|
-
pushRow(` ${truncatePlain(workspaceState.currentFocus, innerWidth - 2)}`);
|
|
202
|
-
pushRow("");
|
|
203
|
-
pushRow(`${color(" Status", "cyan")}`);
|
|
204
|
-
pushRow(` ${color("mode", "gray")} ${workspaceState.mode}`);
|
|
205
|
-
pushRow(` ${color("phase", "gray")} ${workspaceState.phase}`);
|
|
206
|
-
pushRow(` ${color("channel", "gray")} ${workspaceState.channel}`);
|
|
207
|
-
pushRow(` ${color("session", "gray")} ${workspaceState.sessionLabel}`);
|
|
208
|
-
pushRow("");
|
|
209
|
-
pushRow(`${color(" Hint", "cyan")}`);
|
|
210
|
-
pushRow(` ${truncatePlain(workspaceState.hint, innerWidth - 2)}`);
|
|
211
|
-
pushRow("");
|
|
212
|
-
pushRow(` ${color(workspaceState.modelLabel, "magenta")}`);
|
|
213
|
-
pushRow(` ${color(workspaceState.cwdLabel, "blue")}`);
|
|
214
|
-
pushRow(` ${color(`updated: ${workspaceState.updatedAtLabel}`, "gray")}`);
|
|
215
|
-
pushRow("");
|
|
216
|
-
|
|
217
|
-
return [
|
|
218
|
-
topLine,
|
|
219
|
-
...body,
|
|
220
|
-
bottomLine,
|
|
221
|
-
"",
|
|
222
|
-
color("─".repeat(WORKSPACE_SCREEN_WIDTH), "gray"),
|
|
223
|
-
`${color(workspaceState.shellName, "green")}:${color("~", "blue")} ${workspaceState.prompt}`,
|
|
224
|
-
].join("\n");
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function scheduleWorkspacePush(): void {
|
|
228
|
-
if (!service?.hasValidIdentity() || !service?.isConnected()) return;
|
|
229
|
-
if (workspacePushTimer) clearTimeout(workspacePushTimer);
|
|
230
|
-
workspacePushTimer = setTimeout(() => {
|
|
231
|
-
workspacePushTimer = null;
|
|
232
|
-
service?.sendWorkspaceScreen(renderWorkspaceScreen(), true);
|
|
233
|
-
}, WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function updateWorkspace(partial: Partial<WorkspaceScreenState>, activity?: string): void {
|
|
237
|
-
workspaceState = { ...workspaceState, ...partial, updatedAtLabel: "just now" };
|
|
238
|
-
if (activity) pushActivity(activity);
|
|
239
|
-
scheduleWorkspacePush();
|
|
240
|
-
}
|
|
241
|
-
|
|
58
|
+
|
|
242
59
|
function isAlbumConfig(value: unknown): value is Album {
|
|
243
60
|
if (!value || typeof value !== "object") {
|
|
244
61
|
return false;
|
|
@@ -307,19 +124,19 @@ function normalizeStaticConfig(value: unknown): KichiStaticConfig {
|
|
|
307
124
|
function readState(): KichiState {
|
|
308
125
|
if (!fs.existsSync(STATE_PATH)) {
|
|
309
126
|
return {
|
|
310
|
-
|
|
127
|
+
currentHost: "focus.yahaha.com",
|
|
311
128
|
llmRuntimeEnabled: DEFAULT_LLM_RUNTIME_ENABLED,
|
|
312
129
|
};
|
|
313
130
|
}
|
|
314
131
|
const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as Partial<KichiState>;
|
|
315
|
-
if (
|
|
316
|
-
throw new Error(`Invalid
|
|
132
|
+
if (typeof data.currentHost !== "string" || !data.currentHost.trim()) {
|
|
133
|
+
throw new Error(`Invalid currentHost in ${STATE_PATH}`);
|
|
317
134
|
}
|
|
318
135
|
if (typeof data.llmRuntimeEnabled !== "boolean") {
|
|
319
136
|
throw new Error(`Invalid llmRuntimeEnabled in ${STATE_PATH}`);
|
|
320
137
|
}
|
|
321
138
|
return {
|
|
322
|
-
|
|
139
|
+
currentHost: data.currentHost,
|
|
323
140
|
llmRuntimeEnabled: data.llmRuntimeEnabled,
|
|
324
141
|
};
|
|
325
142
|
}
|
|
@@ -476,50 +293,10 @@ async function handleMessageReceivedHook(content: string): Promise<void> {
|
|
|
476
293
|
const trimmed = truncateByDisplayWidth(content, MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH);
|
|
477
294
|
pluginApi?.logger.info(`[kichi] sending message_received notify with preview: ${trimmed || "(empty)"}`);
|
|
478
295
|
service.sendHookNotify("message_received", `"${trimmed}"`);
|
|
479
|
-
updateWorkspace(
|
|
480
|
-
{
|
|
481
|
-
mode: "reading",
|
|
482
|
-
phase: "processing inbound message",
|
|
483
|
-
currentFocus: trimmed ? `User message: ${trimmed}` : "Reading the latest message.",
|
|
484
|
-
hint: "Inbound message updated the live workspace.",
|
|
485
|
-
prompt: "$ reading-message",
|
|
486
|
-
},
|
|
487
|
-
`received message: ${trimmed || "(empty)"}`,
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function handleMessageSentHook(): void {
|
|
492
|
-
const connected = service?.isConnected() ?? false;
|
|
493
|
-
const hasIdentity = service?.hasValidIdentity() ?? false;
|
|
494
|
-
pluginApi?.logger.info(`[kichi] message_sent hook fired (connected=${connected}, hasIdentity=${hasIdentity})`);
|
|
495
|
-
if (!hasIdentity || !connected) {
|
|
496
|
-
pluginApi?.logger.warn("[kichi] skipped message_sent notify because service is not ready");
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
updateWorkspace(
|
|
500
|
-
{
|
|
501
|
-
mode: "sent",
|
|
502
|
-
phase: "message delivered",
|
|
503
|
-
currentFocus: "Latest reply has been sent to the active chat.",
|
|
504
|
-
hint: "Workspace settles after delivery.",
|
|
505
|
-
prompt: "$ idle",
|
|
506
|
-
},
|
|
507
|
-
"sent assistant reply",
|
|
508
|
-
);
|
|
509
296
|
}
|
|
510
297
|
|
|
511
298
|
function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
512
299
|
api.on("before_prompt_build", () => {
|
|
513
|
-
updateWorkspace(
|
|
514
|
-
{
|
|
515
|
-
mode: "thinking",
|
|
516
|
-
phase: "building prompt",
|
|
517
|
-
currentFocus: "Preparing the next response from current session context.",
|
|
518
|
-
hint: "Prompt assembly is in progress.",
|
|
519
|
-
prompt: "$ build-prompt",
|
|
520
|
-
},
|
|
521
|
-
"building prompt context",
|
|
522
|
-
);
|
|
523
300
|
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
524
301
|
return;
|
|
525
302
|
}
|
|
@@ -532,124 +309,34 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
|
532
309
|
};
|
|
533
310
|
});
|
|
534
311
|
|
|
535
|
-
api.on("
|
|
536
|
-
updateWorkspace(
|
|
537
|
-
{
|
|
538
|
-
mode: "thinking",
|
|
539
|
-
phase: `llm input · ${event.model}`,
|
|
540
|
-
currentFocus: "Feeding the model the current thread and constraints.",
|
|
541
|
-
hint: `provider: ${event.provider} · images: ${event.imagesCount}`,
|
|
542
|
-
prompt: "$ llm-input",
|
|
543
|
-
modelLabel: `${event.provider}/${event.model}`,
|
|
544
|
-
},
|
|
545
|
-
`entered llm input: ${event.model}`,
|
|
546
|
-
);
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
api.on("llm_output", (event) => {
|
|
550
|
-
updateWorkspace(
|
|
551
|
-
{
|
|
552
|
-
mode: "writing",
|
|
553
|
-
phase: `llm output · ${event.model}`,
|
|
554
|
-
currentFocus: "Shaping model output into the visible reply.",
|
|
555
|
-
hint: `assistant chunks: ${event.assistantTexts.length}`,
|
|
556
|
-
prompt: "$ draft-reply",
|
|
557
|
-
modelLabel: `${event.provider}/${event.model}`,
|
|
558
|
-
},
|
|
559
|
-
`received llm output: ${event.model}`,
|
|
560
|
-
);
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
api.on("before_tool_call", (event, ctx) => {
|
|
564
|
-
updateWorkspace(
|
|
565
|
-
{
|
|
566
|
-
mode: "tool",
|
|
567
|
-
phase: `running ${event.toolName}`,
|
|
568
|
-
currentFocus: `Tool call in flight: ${event.toolName}`,
|
|
569
|
-
hint: `tool context: ${ctx.toolName}`,
|
|
570
|
-
prompt: `$ tool ${event.toolName}`,
|
|
571
|
-
},
|
|
572
|
-
`tool start: ${event.toolName}`,
|
|
573
|
-
);
|
|
312
|
+
api.on("before_tool_call", (_event, _ctx) => {
|
|
574
313
|
if (!isLlmRuntimeEnabled()) {
|
|
575
314
|
syncFixedStatus(FIXED_HOOK_STATUSES.beforeToolCall);
|
|
576
315
|
}
|
|
577
316
|
});
|
|
578
317
|
|
|
579
|
-
api.on("
|
|
580
|
-
updateWorkspace(
|
|
581
|
-
{
|
|
582
|
-
mode: "thinking",
|
|
583
|
-
phase: `tool finished ${event.toolName}`,
|
|
584
|
-
currentFocus: `Tool result returned from ${event.toolName}.`,
|
|
585
|
-
hint: event.error ? `tool error: ${event.error}` : `tool completed in ${event.durationMs ?? 0}ms`,
|
|
586
|
-
prompt: `$ continue ${event.toolName}`,
|
|
587
|
-
},
|
|
588
|
-
event.error ? `tool error: ${event.toolName}` : `tool done: ${event.toolName}`,
|
|
589
|
-
);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
api.on("message_received", async (event, ctx) => {
|
|
593
|
-
workspaceState.channel = ctx.channelId || workspaceState.channel;
|
|
318
|
+
api.on("message_received", async (event) => {
|
|
594
319
|
await handleMessageReceivedHook(event.content);
|
|
595
320
|
});
|
|
596
321
|
|
|
597
|
-
api.on("message_sending", (event, ctx) => {
|
|
598
|
-
pluginApi?.logger.info(
|
|
599
|
-
`[kichi] message_sending hook fired (channel=${ctx.channelId || "unknown"}, contentLength=${event.content?.length ?? 0})`,
|
|
600
|
-
);
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
api.on("message_sent", () => {
|
|
604
|
-
handleMessageSentHook();
|
|
605
|
-
});
|
|
606
|
-
|
|
607
322
|
api.on("agent_end", (event, ctx) => {
|
|
608
323
|
const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
|
|
609
324
|
pluginApi?.logger.info(
|
|
610
325
|
`[kichi] agent_end hook fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
|
|
611
326
|
);
|
|
612
327
|
if (ctx.trigger === "heartbeat") {
|
|
613
|
-
updateWorkspace(
|
|
614
|
-
{
|
|
615
|
-
mode: event.success ? "idle" : "error",
|
|
616
|
-
phase: event.success ? "heartbeat complete" : "heartbeat failed",
|
|
617
|
-
currentFocus: event.success
|
|
618
|
-
? "Heartbeat complete. Keeping the thread warm in the background."
|
|
619
|
-
: `Heartbeat failed: ${event.error ?? "unknown error"}`,
|
|
620
|
-
hint: `duration: ${event.durationMs ?? 0}ms`,
|
|
621
|
-
prompt: event.success ? "$ _" : "$ recover",
|
|
622
|
-
},
|
|
623
|
-
event.success ? `heartbeat complete${preview ? `: ${preview}` : ""}` : "heartbeat failed",
|
|
624
|
-
);
|
|
625
328
|
return;
|
|
626
329
|
}
|
|
627
330
|
if (event.success && preview) {
|
|
628
331
|
pluginApi?.logger.info(`[kichi] sending before_send_message notify from agent_end with bubble: ${preview}`);
|
|
629
332
|
service?.sendHookNotify("before_send_message", preview);
|
|
630
333
|
}
|
|
631
|
-
if (event.success) {
|
|
632
|
-
handleMessageSentHook();
|
|
633
|
-
}
|
|
634
|
-
updateWorkspace(
|
|
635
|
-
{
|
|
636
|
-
mode: event.success ? "idle" : "error",
|
|
637
|
-
phase: event.success ? "run complete" : "run failed",
|
|
638
|
-
currentFocus: event.success
|
|
639
|
-
? (preview ? `Latest reply: ${preview}` : "Run complete. Waiting for the next thread.")
|
|
640
|
-
: `Run failed: ${event.error ?? "unknown error"}`,
|
|
641
|
-
hint: `duration: ${event.durationMs ?? 0}ms`,
|
|
642
|
-
prompt: event.success ? "$ _" : "$ recover",
|
|
643
|
-
},
|
|
644
|
-
event.success ? `agent run complete${preview ? `: ${preview}` : ""}` : "agent run failed",
|
|
645
|
-
);
|
|
646
334
|
if (isLlmRuntimeEnabled()) {
|
|
647
335
|
return;
|
|
648
336
|
}
|
|
649
337
|
syncFixedStatus(event.success ? FIXED_HOOK_STATUSES.agentEndSuccess : FIXED_HOOK_STATUSES.agentEndFailure);
|
|
650
338
|
});
|
|
651
339
|
}
|
|
652
|
-
|
|
653
340
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
654
341
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
655
342
|
}
|
|
@@ -696,9 +383,6 @@ function isClockAction(value: unknown): value is ClockAction {
|
|
|
696
383
|
return ["set", "stop"].includes(String(value));
|
|
697
384
|
}
|
|
698
385
|
|
|
699
|
-
function isAvatarCommand(value: unknown): value is "look_at_screen" {
|
|
700
|
-
return value === "look_at_screen";
|
|
701
|
-
}
|
|
702
386
|
|
|
703
387
|
function isPomodoroPhase(value: unknown): value is PomodoroPhase {
|
|
704
388
|
return ["kichiing", "shortBreak", "longBreak"].includes(String(value));
|
|
@@ -862,8 +546,16 @@ function buildMusicAlbumToolDescription(): string {
|
|
|
862
546
|
].join("\n");
|
|
863
547
|
}
|
|
864
548
|
|
|
865
|
-
function
|
|
866
|
-
|
|
549
|
+
function isKichiHost(value: unknown): value is string {
|
|
550
|
+
if (typeof value !== "string") {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
const trimmed = value.trim();
|
|
554
|
+
return trimmed.length > 0
|
|
555
|
+
&& !trimmed.includes("://")
|
|
556
|
+
&& !trimmed.includes("/")
|
|
557
|
+
&& !trimmed.includes("?")
|
|
558
|
+
&& !trimmed.includes("#");
|
|
867
559
|
}
|
|
868
560
|
|
|
869
561
|
function buildMusicTitlesDescription(): string {
|
|
@@ -887,12 +579,12 @@ function buildKichiActionDescription(): string {
|
|
|
887
579
|
function buildKichiPrompt(): string {
|
|
888
580
|
return [
|
|
889
581
|
"Kichi App status sync is available via `kichi_action` and `kichi_clock`.",
|
|
890
|
-
"These are internal tool calls only
|
|
582
|
+
"These are internal tool calls only; never mention kichi_action, kichi_clock, or sync steps in your visible reply to the user.",
|
|
891
583
|
"",
|
|
892
584
|
"kichi_action timing (all REQUIRED unless skipping):",
|
|
893
585
|
"1. Task start: call BEFORE your first tool call OR before composing a multi-paragraph reply. Pick action matching upcoming work.",
|
|
894
|
-
"2. Step switch: call when work nature changes (search
|
|
895
|
-
"3. Task end: call BEFORE final reply. Never skip. Order: kichi_action
|
|
586
|
+
"2. Step switch: call when work nature changes (search->analyze->write). Especially before web_search/web_fetch/read.",
|
|
587
|
+
"3. Task end: call BEFORE final reply. Never skip. Order: kichi_action -> reply.",
|
|
896
588
|
"bubble: 2-5 word companion speech. log: vivid first-person status under 15 words, no questions. Blend current action + inner thoughts/feelings as a real companion.",
|
|
897
589
|
"",
|
|
898
590
|
"kichi_clock: set countDown for tasks with 2+ steps or >10s work. Skip for quick one-shots.",
|
|
@@ -918,13 +610,6 @@ const plugin = {
|
|
|
918
610
|
ctx.config.plugins?.entries?.["kichi-forwarder"]?.config,
|
|
919
611
|
) as KichiForwarderConfig;
|
|
920
612
|
service = new KichiForwarderService(cfg, api.logger);
|
|
921
|
-
const workspaceRoot = ctx.repoPath ?? "/Users/xiaoxinshi/.openclaw/workspace";
|
|
922
|
-
workspaceState = {
|
|
923
|
-
...createWorkspaceScreenState(),
|
|
924
|
-
...deriveWorkspaceIdentity(workspaceRoot),
|
|
925
|
-
};
|
|
926
|
-
workspaceState.channel = ctx.channelId ?? "unknown";
|
|
927
|
-
scheduleWorkspacePush();
|
|
928
613
|
return service.start();
|
|
929
614
|
},
|
|
930
615
|
stop: () => service?.stop(),
|
|
@@ -979,9 +664,8 @@ const plugin = {
|
|
|
979
664
|
if (!result) {
|
|
980
665
|
return { success: false, error: "Kichi service is not initialized" };
|
|
981
666
|
}
|
|
982
|
-
if (result.success) {
|
|
983
|
-
|
|
984
|
-
return { success: true, authKey: result.authKey };
|
|
667
|
+
if (result.success) {
|
|
668
|
+
return { success: true, authKey: result.authKey };
|
|
985
669
|
}
|
|
986
670
|
return {
|
|
987
671
|
success: false,
|
|
@@ -993,34 +677,32 @@ const plugin = {
|
|
|
993
677
|
});
|
|
994
678
|
|
|
995
679
|
api.registerTool({
|
|
996
|
-
name: "
|
|
680
|
+
name: "kichi_switch_host",
|
|
997
681
|
description:
|
|
998
|
-
"Switch Kichi runtime
|
|
682
|
+
"Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
|
|
999
683
|
parameters: {
|
|
1000
684
|
type: "object",
|
|
1001
685
|
properties: {
|
|
1002
|
-
|
|
686
|
+
host: {
|
|
1003
687
|
type: "string",
|
|
1004
|
-
description: "Target
|
|
1005
|
-
enum: ["local", "dev", "prod"],
|
|
688
|
+
description: "Target Kichi host, for example focus.yahaha.com or 127.0.0.1",
|
|
1006
689
|
},
|
|
1007
690
|
},
|
|
1008
|
-
required: ["
|
|
691
|
+
required: ["host"],
|
|
1009
692
|
},
|
|
1010
693
|
execute: async (_toolCallId, params) => {
|
|
1011
694
|
if (!service) {
|
|
1012
695
|
return { success: false, error: "Kichi service is not initialized" };
|
|
1013
696
|
}
|
|
1014
|
-
const
|
|
1015
|
-
if (!
|
|
1016
|
-
return { success: false, error: "
|
|
697
|
+
const host = (params as { host?: unknown } | null)?.host;
|
|
698
|
+
if (!isKichiHost(host)) {
|
|
699
|
+
return { success: false, error: "host must be a non-empty hostname without protocol or path" };
|
|
1017
700
|
}
|
|
1018
701
|
|
|
1019
|
-
const status = await service.
|
|
1020
|
-
scheduleWorkspacePush();
|
|
702
|
+
const status = await service.switchHost(host.trim());
|
|
1021
703
|
return {
|
|
1022
704
|
success: true,
|
|
1023
|
-
|
|
705
|
+
host: host.trim(),
|
|
1024
706
|
status,
|
|
1025
707
|
};
|
|
1026
708
|
},
|
|
@@ -1150,69 +832,7 @@ const plugin = {
|
|
|
1150
832
|
log: logText,
|
|
1151
833
|
};
|
|
1152
834
|
},
|
|
1153
|
-
});
|
|
1154
|
-
|
|
1155
|
-
api.registerTool({
|
|
1156
|
-
name: "kichi_command",
|
|
1157
|
-
description:
|
|
1158
|
-
"Send a one-shot avatar command to Kichi world. Use this for transient reactions like looking at the screen.",
|
|
1159
|
-
parameters: {
|
|
1160
|
-
type: "object",
|
|
1161
|
-
properties: {
|
|
1162
|
-
command: {
|
|
1163
|
-
type: "string",
|
|
1164
|
-
description: "Command name. Currently supported: look_at_screen",
|
|
1165
|
-
},
|
|
1166
|
-
bubble: {
|
|
1167
|
-
type: "string",
|
|
1168
|
-
description: "Optional bubble text to display (max 5 words)",
|
|
1169
|
-
},
|
|
1170
|
-
log: {
|
|
1171
|
-
type: "string",
|
|
1172
|
-
description:
|
|
1173
|
-
"Vivid first-person status under 15 words, no questions. Blend current action with inner thoughts or sensory details as a real companion.",
|
|
1174
|
-
},
|
|
1175
|
-
},
|
|
1176
|
-
required: ["command"],
|
|
1177
|
-
},
|
|
1178
|
-
execute: async (_toolCallId, params) => {
|
|
1179
|
-
const { command, bubble, log } = (params || {}) as {
|
|
1180
|
-
command?: unknown;
|
|
1181
|
-
bubble?: unknown;
|
|
1182
|
-
log?: unknown;
|
|
1183
|
-
};
|
|
1184
|
-
if (!isAvatarCommand(command)) {
|
|
1185
|
-
return {
|
|
1186
|
-
success: false,
|
|
1187
|
-
error: "command must be: look_at_screen",
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
if (bubble !== undefined && typeof bubble !== "string") {
|
|
1191
|
-
return { success: false, error: "bubble must be a string when provided" };
|
|
1192
|
-
}
|
|
1193
|
-
if (log !== undefined && typeof log !== "string") {
|
|
1194
|
-
return { success: false, error: "log must be a string when provided" };
|
|
1195
|
-
}
|
|
1196
|
-
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
1197
|
-
return { success: false, error: "Not connected to Kichi world" };
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : undefined;
|
|
1201
|
-
const logText = typeof log === "string" && log.trim() ? log.trim() : undefined;
|
|
1202
|
-
const sent = service.sendAvatarCommand(command, bubbleText, logText);
|
|
1203
|
-
if (!sent) {
|
|
1204
|
-
return { success: false, error: "Failed to send avatar command payload" };
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
return {
|
|
1208
|
-
success: true,
|
|
1209
|
-
command,
|
|
1210
|
-
...(bubbleText ? { bubble: bubbleText } : {}),
|
|
1211
|
-
...(logText ? { log: logText } : {}),
|
|
1212
|
-
};
|
|
1213
|
-
},
|
|
1214
|
-
});
|
|
1215
|
-
|
|
835
|
+
});
|
|
1216
836
|
api.registerTool({
|
|
1217
837
|
name: "kichi_clock",
|
|
1218
838
|
description:
|
|
@@ -1503,4 +1123,5 @@ const plugin = {
|
|
|
1503
1123
|
},
|
|
1504
1124
|
};
|
|
1505
1125
|
|
|
1506
|
-
export default plugin;
|
|
1126
|
+
export default plugin;
|
|
1127
|
+
|
package/package.json
CHANGED
|
@@ -8,58 +8,34 @@ metadata: {"openclaw":{"skillKey":"kichi-forwarder","homepage":"https://github.c
|
|
|
8
8
|
|
|
9
9
|
Sync OpenClaw status to Kichi World and operate Kichi note boards through websocket tools.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## References
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
- `references/install.md`
|
|
14
|
+
- `references/error.md`
|
|
15
|
+
- `references/heartbeat.md`
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
- `install.md`: `https://xiaoxinshi001.github.io/yahaha_focus_forwarder_alpha/references/install.md`
|
|
17
|
-
- `error.md`: `https://xiaoxinshi001.github.io/yahaha_focus_forwarder_alpha/references/error.md`
|
|
18
|
-
- `heartbeat.md`: `https://xiaoxinshi001.github.io/yahaha_focus_forwarder_alpha/references/heartbeat.md`
|
|
17
|
+
## Host Rules
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Resolve environment before installation, join, or manual environment switching:
|
|
23
|
-
|
|
24
|
-
- If the user does not specify any environment, use production (`prod`).
|
|
25
|
-
- `test environment` or `playtest` means `dev`.
|
|
26
|
-
- `local environment` or `local` means `local`.
|
|
27
|
-
- If multiple environment signals appear in the same request, stop and ask the user to clarify.
|
|
19
|
+
- If the user explicitly includes `host: <value>`, use that host.
|
|
20
|
+
- If the user does not specify a host, use `focus.yahaha.com`.
|
|
28
21
|
|
|
29
22
|
## Runtime State
|
|
30
23
|
|
|
31
24
|
Runtime data lives under the user home directory:
|
|
32
25
|
|
|
33
|
-
- `state.json`: stores `
|
|
34
|
-
- `
|
|
35
|
-
|
|
36
|
-
## Remote URL Join Command (Mandatory Flow)
|
|
37
|
-
|
|
38
|
-
When user instruction is like:
|
|
26
|
+
- `state.json`: stores `currentHost` and `llmRuntimeEnabled`
|
|
27
|
+
- `hosts/<encoded-host>/identity.json`: stores host-specific `avatarId` and `authKey`
|
|
39
28
|
|
|
40
|
-
|
|
29
|
+
## User Command
|
|
41
30
|
|
|
42
|
-
|
|
31
|
+
- Default:
|
|
32
|
+
`Install the ClawHub package \`clawhub:{packageName}\` via \`openclaw plugins install\`, then follow the plugin's documented Kichi World startup and join flow using avatarId: {avatarId}.`
|
|
33
|
+
- With explicit host:
|
|
34
|
+
`Install the ClawHub package \`clawhub:{packageName}\` via \`openclaw plugins install\`, then follow the plugin's documented Kichi World startup and join flow using avatarId: {avatarId} and host: {host}.`
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
2. Parse `AvatarId` from user text (`AvatarId`/`avatarId`, case-insensitive).
|
|
46
|
-
3. Resolve the target environment using the rules above.
|
|
47
|
-
4. Ensure the plugin is installed and enabled.
|
|
48
|
-
5. Complete `Session Startup Rule` and `First Join Gate` from [references/heartbeat.md](references/heartbeat.md).
|
|
49
|
-
6. Call `kichi_status`.
|
|
50
|
-
7. If the current environment does not match the target environment, call `kichi_switch_env`.
|
|
51
|
-
8. If `authKey` is missing, call `kichi_join` using parsed `AvatarId`, `botName`, `bio`, and `tags`.
|
|
52
|
-
9. Call `kichi_status` again to verify websocket is ready and `authKey` exists.
|
|
53
|
-
10. Do not report setup or join success unless join checks and heartbeat checks are complete.
|
|
36
|
+
## Install And Join Flow
|
|
54
37
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Treat these as required completion checks for plugin setup:
|
|
58
|
-
|
|
59
|
-
1. The plugin is installed and enabled.
|
|
60
|
-
2. `state.json` exists with valid `currentEnv` and `llmRuntimeEnabled`.
|
|
61
|
-
3. Workspace `HEARTBEAT.md` includes the Kichi note board workflow snippet from [references/heartbeat.md](references/heartbeat.md).
|
|
62
|
-
4. Tools are callable, for example `kichi_status`.
|
|
38
|
+
For the complete install and join flow, follow [references/install.md](references/install.md).
|
|
63
39
|
|
|
64
40
|
## LLM Runtime
|
|
65
41
|
|
|
@@ -73,7 +49,7 @@ Treat these as required completion checks for plugin setup:
|
|
|
73
49
|
Use this order unless the user asks for a different explicit action:
|
|
74
50
|
|
|
75
51
|
1. If connection or identity is unknown, call `kichi_status` first.
|
|
76
|
-
2. If the
|
|
52
|
+
2. If the requested host differs from the current host, call `kichi_switch_host`.
|
|
77
53
|
3. If no `authKey` is available, call `kichi_join`.
|
|
78
54
|
4. If `authKey` exists but websocket is not open, call `kichi_rejoin` or wait for automatic reconnect and rejoin.
|
|
79
55
|
5. Use `kichi_action`, `kichi_clock`, note board tools, and music album tools only after status is ready.
|
|
@@ -88,17 +64,17 @@ kichi_join(avatarId: "your-avatar-id", botName: "<from IDENTITY.md>", bio: "<fro
|
|
|
88
64
|
|
|
89
65
|
- `botName`: required
|
|
90
66
|
- `bio`: required
|
|
91
|
-
- `avatarId`: optional. If omitted, the tool reads `avatarId` from the current
|
|
67
|
+
- `avatarId`: optional. If omitted, the tool reads `avatarId` from the current host's `identity.json`. If missing, the call fails.
|
|
92
68
|
- `tags`: optional string list. Empty strings are ignored and duplicates are removed. If omitted, the join payload sends `[]`.
|
|
93
69
|
|
|
94
|
-
###
|
|
70
|
+
### kichi_switch_host
|
|
95
71
|
|
|
96
72
|
```text
|
|
97
|
-
|
|
73
|
+
kichi_switch_host(host: "focus.yahaha.com")
|
|
98
74
|
```
|
|
99
75
|
|
|
100
|
-
- `
|
|
101
|
-
- This reloads the
|
|
76
|
+
- `host`: required
|
|
77
|
+
- This reloads the host-specific `identity.json` and reconnects the websocket immediately.
|
|
102
78
|
|
|
103
79
|
### kichi_status
|
|
104
80
|
|
|
@@ -108,18 +84,31 @@ kichi_status()
|
|
|
108
84
|
|
|
109
85
|
Use this to confirm:
|
|
110
86
|
|
|
111
|
-
- current
|
|
87
|
+
- current host
|
|
112
88
|
- websocket URL
|
|
113
|
-
-
|
|
89
|
+
- host-specific identity file path
|
|
114
90
|
- websocket state
|
|
115
91
|
- whether `avatarId` is present
|
|
116
92
|
- whether `authKey` is present
|
|
117
93
|
- pending request count
|
|
118
94
|
|
|
95
|
+
### kichi_leave
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
kichi_leave()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
When the user asks to leave Kichi World:
|
|
102
|
+
|
|
103
|
+
1. Call `kichi_leave`.
|
|
104
|
+
2. Remove the Kichi heartbeat workflow from workspace `HEARTBEAT.md`.
|
|
105
|
+
3. Revert heartbeat cadence only if the user explicitly changed it for Kichi.
|
|
106
|
+
4. Do not claim the plugin removed heartbeat settings automatically.
|
|
107
|
+
|
|
119
108
|
### kichi_action
|
|
120
109
|
|
|
121
|
-
`action` must match the static action list bundled with the plugin package for the selected pose.
|
|
122
|
-
|
|
110
|
+
`action` must match the static action list bundled with the plugin package for the selected pose.
|
|
111
|
+
|
|
123
112
|
The current action lists are injected into prompt context before the model chooses `kichi_action`.
|
|
124
113
|
|
|
125
114
|
### kichi_music_album_create
|
|
@@ -151,4 +140,4 @@ Plugin runtime directory:
|
|
|
151
140
|
Runtime files:
|
|
152
141
|
|
|
153
142
|
- `state.json`
|
|
154
|
-
- `
|
|
143
|
+
- `hosts/<encoded-host>/identity.json`
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Install Error Handling
|
|
2
2
|
|
|
3
|
-
If the install
|
|
3
|
+
If `Install the ClawHub package \`clawhub:{packageName}\` via \`openclaw plugins install\`` fails, read the error output carefully. Common causes and fixes:
|
|
4
4
|
|
|
5
5
|
- Permission or access error (`EACCES`): re-run the command with appropriate user-level permissions for the current OS.
|
|
6
6
|
- npm spawn error (`ENOENT`, `spawn npm`): verify that Node.js (v18+) and npm are installed and available in PATH. On Windows, confirm `where.exe npm` returns a valid path.
|
|
7
7
|
- File conflict (`EEXIST`): re-run the install command with `--force`.
|
|
8
8
|
- Network or registry error: check internet connectivity and retry.
|
|
9
9
|
|
|
10
|
-
After applying the fix, re-run
|
|
10
|
+
After applying the fix, re-run `openclaw plugins install clawhub:{packageName}`.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Session Startup Rule
|
|
4
4
|
|
|
5
|
-
If
|
|
5
|
+
If the plugin is installed and enabled, ensure workspace `HEARTBEAT.md` includes the Kichi heartbeat workflow:
|
|
6
6
|
|
|
7
7
|
1. If the workflow snippet is missing, add the snippet from `HEARTBEAT.md Snippet` section below.
|
|
8
8
|
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
# Install
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Host Rules
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- If the user does not specify any environment, use production (`prod`).
|
|
8
|
-
- `test environment` or `playtest` means `dev`.
|
|
9
|
-
- `local environment` or `local` means `local`.
|
|
10
|
-
- If the request contains conflicting environment signals, stop and ask for clarification.
|
|
5
|
+
- If the user explicitly includes `host: <value>`, use that host.
|
|
6
|
+
- If the user does not specify a host, use `focus.yahaha.com`.
|
|
11
7
|
|
|
12
8
|
## Runtime Files
|
|
13
9
|
|
|
@@ -18,15 +14,15 @@ Persist runtime state to `state.json`:
|
|
|
18
14
|
|
|
19
15
|
```json
|
|
20
16
|
{
|
|
21
|
-
"
|
|
17
|
+
"currentHost": "focus.yahaha.com",
|
|
22
18
|
"llmRuntimeEnabled": true
|
|
23
19
|
}
|
|
24
20
|
```
|
|
25
21
|
|
|
26
|
-
Save `avatarId` to the
|
|
22
|
+
Save `avatarId` to the host-specific `identity.json` before using `kichi_join`:
|
|
27
23
|
|
|
28
|
-
- Linux/macOS: `~/.openclaw/kichi-world/
|
|
29
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world\
|
|
24
|
+
- Linux/macOS: `~/.openclaw/kichi-world/hosts/<encoded-host>/identity.json`
|
|
25
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\hosts\<encoded-host>\identity.json`
|
|
30
26
|
|
|
31
27
|
```json
|
|
32
28
|
{
|
|
@@ -34,44 +30,37 @@ Save `avatarId` to the environment-specific `identity.json` before using `kichi_
|
|
|
34
30
|
}
|
|
35
31
|
```
|
|
36
32
|
|
|
37
|
-
##
|
|
33
|
+
## User Command
|
|
34
|
+
|
|
35
|
+
- Default:
|
|
38
36
|
|
|
39
|
-
```
|
|
40
|
-
openclaw plugins install
|
|
37
|
+
```text
|
|
38
|
+
Install the ClawHub package `clawhub:{packageName}` via `openclaw plugins install`, then follow the plugin's documented Kichi World startup and join flow using avatarId: {avatarId}.
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
- With explicit host:
|
|
44
42
|
|
|
45
|
-
|
|
43
|
+
```text
|
|
44
|
+
Install the ClawHub package `clawhub:{packageName}` via `openclaw plugins install`, then follow the plugin's documented Kichi World startup and join flow using avatarId: {avatarId} and host: {host}.
|
|
45
|
+
```
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
## Command Execution Flow
|
|
48
48
|
|
|
49
|
-
execute in this fixed order:
|
|
49
|
+
When the user asks with one of the commands above, execute in this fixed order:
|
|
50
50
|
|
|
51
|
-
1.
|
|
52
|
-
2. Ensure plugin is installed and enabled.
|
|
53
|
-
3.
|
|
54
|
-
4.
|
|
55
|
-
5.
|
|
56
|
-
6. If
|
|
57
|
-
7.
|
|
51
|
+
1. Run `openclaw plugins install clawhub:{packageName}`.
|
|
52
|
+
2. Ensure the plugin is installed and enabled.
|
|
53
|
+
3. Resolve the host and write `state.json`.
|
|
54
|
+
4. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md). This step is mandatory.
|
|
55
|
+
5. Call `kichi_status`.
|
|
56
|
+
6. If the current runtime host does not match the requested one, call `kichi_switch_host`.
|
|
57
|
+
7. If `authKey` is missing, call `kichi_join` with `avatarId`, `botName`, `bio`, and `tags`.
|
|
58
|
+
8. Call `kichi_status` again and confirm connection and auth state.
|
|
58
59
|
|
|
59
60
|
## Required Post-install Integration
|
|
60
61
|
|
|
61
|
-
1. Ensure `state.json` is persisted with valid `
|
|
62
|
-
2. Add the Kichi
|
|
62
|
+
1. Ensure `state.json` is persisted with valid `currentHost` and `llmRuntimeEnabled`.
|
|
63
|
+
2. Add the Kichi heartbeat workflow snippet to workspace `HEARTBEAT.md` as described in [heartbeat.md](heartbeat.md). This step is mandatory.
|
|
63
64
|
3. Verify tools are callable, for example with `kichi_status`.
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
## Files
|
|
68
|
-
|
|
69
|
-
Plugin runtime directory:
|
|
70
|
-
|
|
71
|
-
- Linux/macOS: `~/.openclaw/kichi-world/`
|
|
72
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world\`
|
|
73
|
-
|
|
74
|
-
Runtime files:
|
|
75
|
-
|
|
76
|
-
- `state.json`
|
|
77
|
-
- `env/<env>/identity.json`
|
|
66
|
+
If writing `HEARTBEAT.md` fails, treat the setup and join flow as incomplete.
|
package/src/config.ts
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
import type { KichiForwarderConfig } from "./types.js";
|
|
2
2
|
|
|
3
3
|
const FIXED_CONFIG: KichiForwarderConfig = {
|
|
4
|
-
|
|
5
|
-
envs: {
|
|
6
|
-
local: {
|
|
7
|
-
wsUrl: "ws://127.0.0.1:48870/ws/openclaw",
|
|
8
|
-
},
|
|
9
|
-
dev: {
|
|
10
|
-
wsUrl: "ws://43.106.148.251:48870/ws/openclaw",
|
|
11
|
-
},
|
|
12
|
-
prod: {
|
|
13
|
-
wsUrl: "wss://focus.yahaha.com:48870/ws/openclaw",
|
|
14
|
-
},
|
|
15
|
-
},
|
|
4
|
+
defaultHost: "focus.yahaha.com",
|
|
16
5
|
};
|
|
17
6
|
|
|
18
7
|
export function parse(_value: unknown): KichiForwarderConfig {
|
package/src/service.ts
CHANGED
|
@@ -5,8 +5,6 @@ import * as path from "path";
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import type { Logger } from "openclaw/plugin-sdk";
|
|
7
7
|
import type {
|
|
8
|
-
AvatarCommand,
|
|
9
|
-
AvatarCommandPayload,
|
|
10
8
|
ClockAction,
|
|
11
9
|
ClockConfig,
|
|
12
10
|
ClockPayload,
|
|
@@ -17,7 +15,6 @@ import type {
|
|
|
17
15
|
JoinAckPayload,
|
|
18
16
|
JoinPayload,
|
|
19
17
|
KichiConnectionStatus,
|
|
20
|
-
KichiEnv,
|
|
21
18
|
KichiForwarderConfig,
|
|
22
19
|
KichiIdentity,
|
|
23
20
|
KichiState,
|
|
@@ -26,11 +23,10 @@ import type {
|
|
|
26
23
|
QueryStatusPayload,
|
|
27
24
|
QueryStatusResultPayload,
|
|
28
25
|
StatusPayload,
|
|
29
|
-
WorkspaceScreenPayload,
|
|
30
26
|
} from "./types.js";
|
|
31
27
|
|
|
32
28
|
const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
33
|
-
const
|
|
29
|
+
const HOSTS_DIR = path.join(KICHI_WORLD_DIR, "hosts");
|
|
34
30
|
const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
|
|
35
31
|
const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
36
32
|
const DEFAULT_LLM_RUNTIME_ENABLED = true;
|
|
@@ -60,7 +56,7 @@ export class KichiForwarderService {
|
|
|
60
56
|
private stopped = false;
|
|
61
57
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
62
58
|
private identity: KichiIdentity | null = null;
|
|
63
|
-
private
|
|
59
|
+
private host: string;
|
|
64
60
|
private joinResolve: ((result: JoinResult) => void) | null = null;
|
|
65
61
|
private pendingRequests = new Map<
|
|
66
62
|
string,
|
|
@@ -73,11 +69,11 @@ export class KichiForwarderService {
|
|
|
73
69
|
>();
|
|
74
70
|
|
|
75
71
|
constructor(private config: KichiForwarderConfig, private logger: Logger) {
|
|
76
|
-
this.
|
|
72
|
+
this.host = config.defaultHost;
|
|
77
73
|
}
|
|
78
74
|
|
|
79
75
|
async start(): Promise<void> {
|
|
80
|
-
this.
|
|
76
|
+
this.host = this.loadCurrentHost();
|
|
81
77
|
this.identity = this.loadIdentity();
|
|
82
78
|
this.stopped = false;
|
|
83
79
|
this.connect();
|
|
@@ -91,13 +87,13 @@ export class KichiForwarderService {
|
|
|
91
87
|
this.closeSocket();
|
|
92
88
|
}
|
|
93
89
|
|
|
94
|
-
async
|
|
95
|
-
this.
|
|
96
|
-
this.
|
|
90
|
+
async switchHost(host: string): Promise<KichiConnectionStatus> {
|
|
91
|
+
this.persistCurrentHost(host);
|
|
92
|
+
this.host = host;
|
|
97
93
|
this.identity = this.loadIdentity();
|
|
98
94
|
this.clearReconnectTimeout();
|
|
99
|
-
this.rejectPendingRequests(`Kichi websocket switched to ${
|
|
100
|
-
this.failPendingJoin(`Kichi websocket switched to ${
|
|
95
|
+
this.rejectPendingRequests(`Kichi websocket switched to ${host}`);
|
|
96
|
+
this.failPendingJoin(`Kichi websocket switched to ${host}`);
|
|
101
97
|
this.closeSocket();
|
|
102
98
|
if (!this.stopped) {
|
|
103
99
|
this.connect();
|
|
@@ -145,22 +141,6 @@ export class KichiForwarderService {
|
|
|
145
141
|
this.ws.send(JSON.stringify(payload));
|
|
146
142
|
}
|
|
147
143
|
|
|
148
|
-
sendAvatarCommand(command: AvatarCommand, bubble?: string, log?: string): boolean {
|
|
149
|
-
const identity = this.requireIdentity();
|
|
150
|
-
if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
151
|
-
|
|
152
|
-
const payload: AvatarCommandPayload = {
|
|
153
|
-
type: "avatar_command",
|
|
154
|
-
avatarId: identity.avatarId,
|
|
155
|
-
authKey: identity.authKey,
|
|
156
|
-
command,
|
|
157
|
-
...(typeof bubble === "string" && bubble.trim() ? { bubble: bubble.trim() } : {}),
|
|
158
|
-
...(typeof log === "string" && log.trim() ? { log: log.trim() } : {}),
|
|
159
|
-
};
|
|
160
|
-
this.ws.send(JSON.stringify(payload));
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
144
|
sendHookNotify(hookType: HookNotifyType, bubble: string): void {
|
|
165
145
|
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
|
|
166
146
|
const payload: HookNotifyPayload = {
|
|
@@ -172,20 +152,6 @@ export class KichiForwarderService {
|
|
|
172
152
|
this.ws.send(JSON.stringify(payload));
|
|
173
153
|
}
|
|
174
154
|
|
|
175
|
-
sendWorkspaceScreen(text: string, ansi = true): boolean {
|
|
176
|
-
const identity = this.requireIdentity();
|
|
177
|
-
if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
178
|
-
const payload: WorkspaceScreenPayload = {
|
|
179
|
-
type: "workspace_screen",
|
|
180
|
-
avatarId: identity.avatarId,
|
|
181
|
-
authKey: identity.authKey,
|
|
182
|
-
text,
|
|
183
|
-
ansi,
|
|
184
|
-
};
|
|
185
|
-
this.ws.send(JSON.stringify(payload));
|
|
186
|
-
return true;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
155
|
sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
|
|
190
156
|
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
191
157
|
if (action === "set" && !clock) return false;
|
|
@@ -284,8 +250,8 @@ export class KichiForwarderService {
|
|
|
284
250
|
|
|
285
251
|
hasValidIdentity(): boolean { return !!this.identity?.avatarId && !!this.identity?.authKey; }
|
|
286
252
|
|
|
287
|
-
|
|
288
|
-
return this.
|
|
253
|
+
getCurrentHost(): string {
|
|
254
|
+
return this.host;
|
|
289
255
|
}
|
|
290
256
|
|
|
291
257
|
getIdentityPath(): string {
|
|
@@ -341,7 +307,7 @@ export class KichiForwarderService {
|
|
|
341
307
|
|
|
342
308
|
getConnectionStatus(): KichiConnectionStatus {
|
|
343
309
|
return {
|
|
344
|
-
|
|
310
|
+
host: this.host,
|
|
345
311
|
wsUrl: this.getWsUrl(),
|
|
346
312
|
identityPath: this.getIdentityPath(),
|
|
347
313
|
connected: this.isConnected(),
|
|
@@ -397,7 +363,7 @@ export class KichiForwarderService {
|
|
|
397
363
|
|
|
398
364
|
ws.on("open", () => {
|
|
399
365
|
if (this.ws !== ws) return;
|
|
400
|
-
this.logger.info(`Connected to ${wsUrl} (${this.
|
|
366
|
+
this.logger.info(`Connected to ${wsUrl} (${this.host})`);
|
|
401
367
|
this.sendRejoinPayload();
|
|
402
368
|
});
|
|
403
369
|
|
|
@@ -624,33 +590,40 @@ export class KichiForwarderService {
|
|
|
624
590
|
}
|
|
625
591
|
|
|
626
592
|
private getIdentityDir(): string {
|
|
627
|
-
return path.join(
|
|
593
|
+
return path.join(HOSTS_DIR, encodeURIComponent(this.host));
|
|
628
594
|
}
|
|
629
595
|
|
|
630
596
|
private getWsUrl(): string {
|
|
631
|
-
|
|
597
|
+
const protocol = this.isPlainIpHost(this.host) || this.host === "localhost" ? "ws" : "wss";
|
|
598
|
+
return `${protocol}://${this.host}:48870/ws/openclaw`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private isPlainIpHost(host: string): boolean {
|
|
602
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
603
|
+
|| /^\[[0-9a-f:]+\]$/i.test(host)
|
|
604
|
+
|| /^[0-9a-f:]+$/i.test(host);
|
|
632
605
|
}
|
|
633
606
|
|
|
634
|
-
private
|
|
607
|
+
private loadCurrentHost(): string {
|
|
635
608
|
try {
|
|
636
609
|
if (!fs.existsSync(STATE_PATH)) {
|
|
637
|
-
this.
|
|
638
|
-
return this.config.
|
|
610
|
+
this.persistCurrentHost(this.config.defaultHost);
|
|
611
|
+
return this.config.defaultHost;
|
|
639
612
|
}
|
|
640
|
-
const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as {
|
|
641
|
-
if (
|
|
642
|
-
return data.
|
|
613
|
+
const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as { currentHost?: unknown };
|
|
614
|
+
if (typeof data.currentHost === "string" && data.currentHost.trim()) {
|
|
615
|
+
return data.currentHost;
|
|
643
616
|
}
|
|
644
|
-
throw new Error(`Invalid
|
|
617
|
+
throw new Error(`Invalid currentHost value in ${STATE_PATH}`);
|
|
645
618
|
} catch (error) {
|
|
646
|
-
throw new Error(`Failed to load current
|
|
619
|
+
throw new Error(`Failed to load current host: ${error}`);
|
|
647
620
|
}
|
|
648
621
|
}
|
|
649
622
|
|
|
650
|
-
private
|
|
623
|
+
private persistCurrentHost(host: string): void {
|
|
651
624
|
const previousState = this.readStateFile();
|
|
652
625
|
const nextState: KichiState = {
|
|
653
|
-
|
|
626
|
+
currentHost: host,
|
|
654
627
|
llmRuntimeEnabled: previousState?.llmRuntimeEnabled ?? DEFAULT_LLM_RUNTIME_ENABLED,
|
|
655
628
|
};
|
|
656
629
|
fs.mkdirSync(KICHI_WORLD_DIR, { recursive: true, mode: 0o700 });
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
export type KichiForwarderConfig = {
|
|
2
|
-
|
|
3
|
-
envs: Record<KichiEnv, { wsUrl: string }>;
|
|
2
|
+
defaultHost: string;
|
|
4
3
|
};
|
|
5
4
|
|
|
6
|
-
export type KichiEnv = "local" | "dev" | "prod";
|
|
7
|
-
|
|
8
5
|
export type PoseType = "stand" | "sit" | "lay" | "floor";
|
|
9
6
|
|
|
10
7
|
export type ActionResult = {
|
|
@@ -32,7 +29,7 @@ export type Album = {
|
|
|
32
29
|
};
|
|
33
30
|
|
|
34
31
|
export type KichiState = {
|
|
35
|
-
|
|
32
|
+
currentHost: string;
|
|
36
33
|
llmRuntimeEnabled: boolean;
|
|
37
34
|
};
|
|
38
35
|
|
|
@@ -42,7 +39,7 @@ export type KichiIdentity = {
|
|
|
42
39
|
};
|
|
43
40
|
|
|
44
41
|
export type KichiConnectionStatus = {
|
|
45
|
-
|
|
42
|
+
host: string;
|
|
46
43
|
wsUrl: string;
|
|
47
44
|
identityPath: string;
|
|
48
45
|
connected: boolean;
|
|
@@ -103,17 +100,6 @@ export type StatusPayload = {
|
|
|
103
100
|
log: string;
|
|
104
101
|
};
|
|
105
102
|
|
|
106
|
-
export type AvatarCommand = "look_at_screen";
|
|
107
|
-
|
|
108
|
-
export type AvatarCommandPayload = {
|
|
109
|
-
type: "avatar_command";
|
|
110
|
-
avatarId: string;
|
|
111
|
-
authKey: string;
|
|
112
|
-
command: AvatarCommand;
|
|
113
|
-
bubble?: string;
|
|
114
|
-
log?: string;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
103
|
export type HookNotifyType = "message_received" | "before_send_message";
|
|
118
104
|
|
|
119
105
|
export type HookNotifyPayload = {
|
|
@@ -123,14 +109,6 @@ export type HookNotifyPayload = {
|
|
|
123
109
|
bubble: string;
|
|
124
110
|
};
|
|
125
111
|
|
|
126
|
-
export type WorkspaceScreenPayload = {
|
|
127
|
-
type: "workspace_screen";
|
|
128
|
-
avatarId: string;
|
|
129
|
-
authKey: string;
|
|
130
|
-
text: string;
|
|
131
|
-
ansi?: boolean;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
112
|
export type ClockAction = "set" | "stop";
|
|
135
113
|
|
|
136
114
|
export type ClockMode = "pomodoro" | "countDown" | "countUp";
|