@yahaha-studio/kichi-forwarder 0.0.1-alpha.49 → 0.0.1-alpha.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,6 +1,7 @@
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
+ import { fileURLToPath } from "node:url";
4
5
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
6
  import { parse } from "./src/config.js";
6
7
  import { KichiForwarderService } from "./src/service.js";
@@ -9,23 +10,14 @@ import type {
9
10
  Album,
10
11
  ClockAction,
11
12
  ClockConfig,
12
- KichiRuntimeConfig,
13
13
  KichiForwarderConfig,
14
+ KichiState,
15
+ KichiStaticConfig,
14
16
  PomodoroPhase,
15
17
  PoseType,
16
18
  } from "./src/types.js";
17
-
18
- const DEFAULT_ACTIONS: KichiRuntimeConfig["actions"] = {
19
- stand: ["High Five", "Listen Music", "Arm Stretch", "Backbend Stretch", "Making Selfie", "Arms Crossed", "Epiphany", "Angry", "Yay", "Dance", "Sing", "Tired", "Wait", "Stand Phone Talk", "Stand Phone Play", "Curtsy", "Stand Writing", "Stand Drawing", "Stand Play Guitar", "Stand Typing with Keyboard", "Cry", "Dance with Joy", "Float", "Hand on Chest", "Horse Stance", "Idle Backup Hands", "No", "Panic", "Playful Point Up", "Rub Hands", "Run Jump", "Star Showing", "Walk", "Goofy Moves", "Reading"],
20
- sit: ["Typing with Keyboard", "Thinking", "Writing", "Crazy", "Hand Cramp", "Dozing", "Phone Talk", "Situp with Arms Crossed", "Situp with Cross Legs", "Eating", "Laze with Cross Legs", "Sit with Arm Stretch", "Drink", "Sit with Making Selfie", "Play Game", "Situp Sleep", "Sit Phone Play", "Painting", "Daze", "Trace Circles", "Reading", "Contemplate", "Chin Rest", "Sleep with Table", "Cute Chin Rest", "Sit Nicely", "Sit Play Guitar", "Meditate"],
21
- lay: ["Bend One Knee", "Sleep Curl up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side", "Lay Writing", "Lay Painting", "Sleep Getup", "Starfish", "Lie Side Play Phone", "Prone Play Phone", "Play Laptop"],
22
- floor: ["Seiza", "Cross Legged", "Knee Hug", "Writing", "Painting", "Floor Phone Play", "Typing with Keyboard", "Reading", "Phone Talk", "Phone Talk with Point", "Thinking", "Yawn", "Chin Rest", "Finger Tap Chin", "Arm Stretch", "Crazy", "Remorse", "Tantrum", "Squat", "Cross Legs", "Lean Sit", "Playful Point up", "Swing Legs", "Drained", "Meditate"],
23
- };
24
-
25
- const DEFAULT_RUNTIME_CONFIG: KichiRuntimeConfig = {
26
- actions: DEFAULT_ACTIONS,
27
- llmRuntimeEnabled: true,
28
- };
19
+ const BUNDLED_STATIC_CONFIG_PATH = new URL("./config/kichi-config.json", import.meta.url);
20
+ const DEFAULT_LLM_RUNTIME_ENABLED = true;
29
21
  const FIXED_HOOK_STATUSES: Record<string, ActionResult> = {
30
22
  beforePromptBuild: {
31
23
  poseType: "sit",
@@ -53,215 +45,13 @@ const FIXED_HOOK_STATUSES: Record<string, ActionResult> = {
53
45
  },
54
46
  };
55
47
 
56
- const MESSAGE_SENT_BUBBLES = [
57
- "All set!",
58
- "Sent.",
59
- "Delivered.",
60
- "Done and sent.",
61
- "It's out.",
62
- "All yours.",
63
- ];
64
-
65
48
  const KICHI_WORLD_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
66
- const RUNTIME_CONFIG_PATH = path.join(KICHI_WORLD_DIR, "kichi-runtime-config.json");
67
- const LEGACY_SKILLS_CONFIG_PATH = path.join(KICHI_WORLD_DIR, "skills-config.json");
68
- const IDENTITY_PATH = path.join(KICHI_WORLD_DIR, "identity.json");
69
- const RUNTIME_ALBUM_CONFIG_PATH = path.join(KICHI_WORLD_DIR, "album-config.json");
49
+ const STATE_PATH = path.join(KICHI_WORLD_DIR, "state.json");
70
50
  const MAX_NOTEBOARD_TEXT_LENGTH = 200;
71
51
  const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
72
52
  const MAX_AGENT_END_PREVIEW_WIDTH = 10;
73
- const MESSAGE_RECEIVED_ELLIPSIS = "...";
74
- const BUNDLED_ALBUM_CONFIG_PATH = new URL("./config/album-config.json", import.meta.url);
75
- const ANSI = {
76
- reset: "\u001b[0m",
77
- bold: "\u001b[1m",
78
- dim: "\u001b[2m",
79
- cyan: "\u001b[36m",
80
- green: "\u001b[32m",
81
- yellow: "\u001b[33m",
82
- magenta: "\u001b[35m",
83
- blue: "\u001b[34m",
84
- gray: "\u001b[90m",
85
- white: "\u001b[37m",
86
- };
87
- const WORKSPACE_SCREEN_WIDTH = 109;
88
- const WORKSPACE_ACTIVITY_LIMIT = 8;
89
- const WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS = 150;
90
- let cachedConfig: KichiRuntimeConfig | null = null;
91
- let cachedConfigMtime = 0;
92
- let cachedConfigPath = "";
93
- let cachedAlbumConfig: Album | null = null;
94
- let cachedAlbumConfigMtime = 0;
95
- let service: KichiForwarderService | null = null;
96
- let pluginApi: OpenClawPluginApi | null = null;
97
- let workspaceState: WorkspaceScreenState = createWorkspaceScreenState();
98
- let workspacePushTimer: NodeJS.Timeout | null = null;
99
-
100
- type WorkspaceScreenState = {
101
- sessionLabel: string;
102
- mode: string;
103
- phase: string;
104
- channel: string;
105
- updatedAtLabel: string;
106
- recentActivity: string[];
107
- currentFocus: string;
108
- hint: string;
109
- prompt: string;
110
- title: string;
111
- shellName: string;
112
- cwdLabel: string;
113
- modelLabel: string;
114
- };
115
-
116
- function createWorkspaceScreenState(): WorkspaceScreenState {
117
- return {
118
- sessionLabel: "main",
119
- mode: "idle",
120
- phase: "waiting",
121
- channel: "unknown",
122
- updatedAtLabel: "just now",
123
- recentActivity: [],
124
- currentFocus: "Waiting for the next thread to pick up.",
125
- hint: "Low-noise live workspace view.",
126
- prompt: "$ _",
127
- title: "Workspace",
128
- shellName: "agent",
129
- cwdLabel: process.cwd(),
130
- modelLabel: "model: unknown",
131
- };
132
- }
133
-
134
- function normalizeShellToken(value: string): string {
135
- const normalized = value
136
- .trim()
137
- .toLowerCase()
138
- .replace(/[^a-z0-9._-]+/g, "-")
139
- .replace(/^-+|-+$/g, "");
140
- return normalized || "agent";
141
- }
142
-
143
- function parseIdentityNameFromWorkspace(workspaceRoot: string): string | null {
144
- try {
145
- const identityPath = path.join(workspaceRoot, "IDENTITY.md");
146
- if (!fs.existsSync(identityPath)) return null;
147
- const raw = fs.readFileSync(identityPath, "utf-8");
148
- const match = raw.match(/^-\s*\*\*Name:\*\*\s*(.+)$/m);
149
- const value = match?.[1]?.trim();
150
- return value || null;
151
- } catch {
152
- return null;
153
- }
154
- }
155
-
156
- function deriveWorkspaceIdentity(workspaceRoot: string): Pick<WorkspaceScreenState, "title" | "shellName" | "cwdLabel"> {
157
- const identityName = parseIdentityNameFromWorkspace(workspaceRoot);
158
- const titleBase = identityName || path.basename(workspaceRoot) || "workspace";
159
- return {
160
- title: `${titleBase} Workspace`,
161
- shellName: normalizeShellToken(identityName || titleBase),
162
- cwdLabel: workspaceRoot,
163
- };
164
- }
165
-
166
- function nowLabel(): string {
167
- const date = new Date();
168
- return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
169
- }
170
-
171
- function stripAnsi(text: string): string {
172
- return text.replace(/\u001b\[[0-9;]*m/g, "");
173
- }
174
-
175
- function visibleLength(text: string): number {
176
- return Array.from(stripAnsi(text)).length;
177
- }
178
-
179
- function padVisible(text: string, width: number): string {
180
- const pad = Math.max(0, width - visibleLength(text));
181
- return text + " ".repeat(pad);
182
- }
183
-
184
- function truncatePlain(text: string, width: number): string {
185
- const chars = Array.from(text);
186
- if (chars.length <= width) return text;
187
- if (width <= 1) return chars.slice(0, width).join("");
188
- return chars.slice(0, width - 1).join("") + "…";
189
- }
190
-
191
- function color(text: string, tone: keyof typeof ANSI): string {
192
- return `${ANSI[tone]}${text}${ANSI.reset}`;
193
- }
194
-
195
- function pushActivity(line: string): void {
196
- if (!line.trim()) return;
197
- const stamped = `[${nowLabel()}] ${line.trim()}`;
198
- workspaceState.recentActivity.unshift(stamped);
199
- workspaceState.recentActivity = workspaceState.recentActivity.slice(0, WORKSPACE_ACTIVITY_LIMIT);
200
- workspaceState.updatedAtLabel = "just now";
201
- }
202
-
203
- function renderWorkspaceScreen(): string {
204
- const innerWidth = WORKSPACE_SCREEN_WIDTH - 2;
205
- const topTitle = `${color(` ${workspaceState.title} `, "bold")}${color("live session", "gray")}`;
206
- const topLine = `╭─── ${topTitle}${"─".repeat(Math.max(0, innerWidth - 4 - visibleLength(topTitle)))}╮`;
207
- const bottomLine = `╰${"─".repeat(innerWidth)}╯`;
208
-
209
- const body: string[] = [];
210
- const pushRow = (text = "") => {
211
- body.push(`│${padVisible(text, innerWidth)}│`);
212
- };
213
-
214
- pushRow("");
215
- pushRow(`${color(" Welcome back.", "white")}`);
216
- pushRow(`${color(" Current session is active and mirrored as a terminal-style workspace.", "gray")}`);
217
- pushRow("");
218
- pushRow(`${color(" Recent activity", "cyan")}`);
219
- for (const item of workspaceState.recentActivity.length ? workspaceState.recentActivity : ["[--:--:--] idle"] ) {
220
- pushRow(` ${color("•", "green")} ${truncatePlain(item, innerWidth - 4)}`);
221
- }
222
- pushRow("");
223
- pushRow(`${color(" Current focus", "cyan")}`);
224
- pushRow(` ${truncatePlain(workspaceState.currentFocus, innerWidth - 2)}`);
225
- pushRow("");
226
- pushRow(`${color(" Status", "cyan")}`);
227
- pushRow(` ${color("mode", "gray")} ${workspaceState.mode}`);
228
- pushRow(` ${color("phase", "gray")} ${workspaceState.phase}`);
229
- pushRow(` ${color("channel", "gray")} ${workspaceState.channel}`);
230
- pushRow(` ${color("session", "gray")} ${workspaceState.sessionLabel}`);
231
- pushRow("");
232
- pushRow(`${color(" Hint", "cyan")}`);
233
- pushRow(` ${truncatePlain(workspaceState.hint, innerWidth - 2)}`);
234
- pushRow("");
235
- pushRow(` ${color(workspaceState.modelLabel, "magenta")}`);
236
- pushRow(` ${color(workspaceState.cwdLabel, "blue")}`);
237
- pushRow(` ${color(`updated: ${workspaceState.updatedAtLabel}`, "gray")}`);
238
- pushRow("");
239
-
240
- return [
241
- topLine,
242
- ...body,
243
- bottomLine,
244
- "",
245
- color("─".repeat(WORKSPACE_SCREEN_WIDTH), "gray"),
246
- `${color(workspaceState.shellName, "green")}:${color("~", "blue")} ${workspaceState.prompt}`,
247
- ].join("\n");
248
- }
249
-
250
- function scheduleWorkspacePush(): void {
251
- if (!service?.hasValidIdentity() || !service?.isConnected()) return;
252
- if (workspacePushTimer) clearTimeout(workspacePushTimer);
253
- workspacePushTimer = setTimeout(() => {
254
- workspacePushTimer = null;
255
- service?.sendWorkspaceScreen(renderWorkspaceScreen(), true);
256
- }, WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS);
257
- }
258
-
259
- function updateWorkspace(partial: Partial<WorkspaceScreenState>, activity?: string): void {
260
- workspaceState = { ...workspaceState, ...partial, updatedAtLabel: "just now" };
261
- if (activity) pushActivity(activity);
262
- scheduleWorkspacePush();
263
- }
264
-
53
+ const MESSAGE_RECEIVED_ELLIPSIS = "...";
54
+
265
55
  function isAlbumConfig(value: unknown): value is Album {
266
56
  if (!value || typeof value !== "object") {
267
57
  return false;
@@ -283,39 +73,8 @@ function isAlbumConfig(value: unknown): value is Album {
283
73
  });
284
74
  }
285
75
 
286
- function loadAlbumConfigFromPath(configPath: string | URL): Album {
287
- const raw = fs.readFileSync(configPath, "utf-8");
288
- const parsed = JSON.parse(raw) as unknown;
289
- if (!isAlbumConfig(parsed)) {
290
- throw new Error(`Invalid album config at ${String(configPath)}`);
291
- }
292
- return parsed;
293
- }
294
-
295
- function ensureRuntimeAlbumConfig(): void {
296
- fs.mkdirSync(KICHI_WORLD_DIR, { recursive: true });
297
- if (!fs.existsSync(RUNTIME_ALBUM_CONFIG_PATH)) {
298
- fs.copyFileSync(BUNDLED_ALBUM_CONFIG_PATH, RUNTIME_ALBUM_CONFIG_PATH);
299
- pluginApi?.logger.debug("[kichi] seeded runtime album config from bundled config");
300
- return;
301
- }
302
-
303
- try {
304
- loadAlbumConfigFromPath(RUNTIME_ALBUM_CONFIG_PATH);
305
- } catch (error) {
306
- pluginApi?.logger.warn(`[kichi] invalid runtime album config, resetting from bundled config: ${error}`);
307
- fs.copyFileSync(BUNDLED_ALBUM_CONFIG_PATH, RUNTIME_ALBUM_CONFIG_PATH);
308
- }
309
- }
310
-
311
76
  function loadRuntimeAlbumConfig(): Album {
312
- ensureRuntimeAlbumConfig();
313
- const stat = fs.statSync(RUNTIME_ALBUM_CONFIG_PATH);
314
- if (!cachedAlbumConfig || stat.mtimeMs !== cachedAlbumConfigMtime) {
315
- cachedAlbumConfig = loadAlbumConfigFromPath(RUNTIME_ALBUM_CONFIG_PATH);
316
- cachedAlbumConfigMtime = stat.mtimeMs;
317
- }
318
- return cachedAlbumConfig;
77
+ return loadStaticConfig().album;
319
78
  }
320
79
 
321
80
  function getMusicTitleLookup(): Map<string, string> {
@@ -332,70 +91,61 @@ function getMusicTitleExamples(): string[] {
332
91
  return loadRuntimeAlbumConfig().track.slice(0, 10).map((item) => item.name);
333
92
  }
334
93
 
335
- function sanitizeActions(value: unknown, fallback: string[]): string[] {
336
- if (!Array.isArray(value)) {
337
- return fallback;
94
+ function isPoseActions(value: unknown): value is Record<PoseType, string[]> {
95
+ if (!value || typeof value !== "object") {
96
+ return false;
338
97
  }
339
- const actions = value.filter(
340
- (item): item is string => typeof item === "string" && item.trim().length > 0,
341
- );
342
- return actions.length > 0 ? actions : fallback;
98
+ const actions = value as Partial<Record<PoseType, unknown>>;
99
+ return ["stand", "sit", "lay", "floor"].every((pose) =>
100
+ Array.isArray(actions[pose as PoseType])
101
+ && (actions[pose as PoseType] as unknown[]).every((item) => typeof item === "string" && item.trim().length > 0));
343
102
  }
344
103
 
345
- function normalizeRuntimeConfig(value: unknown): KichiRuntimeConfig {
346
- const raw = value && typeof value === "object" ? (value as Partial<KichiRuntimeConfig>) : {};
104
+ function normalizeStaticConfig(value: unknown): KichiStaticConfig {
105
+ const raw = value && typeof value === "object" ? (value as Partial<KichiStaticConfig>) : {};
347
106
  const actions = raw.actions;
107
+ const album = raw.album;
108
+ if (!isPoseActions(actions)) {
109
+ throw new Error("config/kichi-config.json must include valid actions");
110
+ }
111
+ if (!isAlbumConfig(album)) {
112
+ throw new Error("config/kichi-config.json must include a valid album object");
113
+ }
348
114
  return {
349
- llmRuntimeEnabled: typeof raw.llmRuntimeEnabled === "boolean" ? raw.llmRuntimeEnabled : true,
350
- actions: {
351
- stand: sanitizeActions(actions?.stand, DEFAULT_ACTIONS.stand),
352
- sit: sanitizeActions(actions?.sit, DEFAULT_ACTIONS.sit),
353
- lay: sanitizeActions(actions?.lay, DEFAULT_ACTIONS.lay),
354
- floor: sanitizeActions(actions?.floor, DEFAULT_ACTIONS.floor),
355
- },
115
+ album,
116
+ actions,
356
117
  };
357
118
  }
358
119
 
359
- function resolveRuntimeConfigPath(): string | null {
360
- if (fs.existsSync(RUNTIME_CONFIG_PATH)) {
361
- return RUNTIME_CONFIG_PATH;
120
+ function readState(): KichiState {
121
+ if (!fs.existsSync(STATE_PATH)) {
122
+ return {
123
+ currentHost: "focus.yahaha.com",
124
+ llmRuntimeEnabled: DEFAULT_LLM_RUNTIME_ENABLED,
125
+ };
362
126
  }
363
- if (fs.existsSync(LEGACY_SKILLS_CONFIG_PATH)) {
364
- return LEGACY_SKILLS_CONFIG_PATH;
127
+ const data = JSON.parse(fs.readFileSync(STATE_PATH, "utf-8")) as Partial<KichiState>;
128
+ if (typeof data.currentHost !== "string" || !data.currentHost.trim()) {
129
+ throw new Error(`Invalid currentHost in ${STATE_PATH}`);
365
130
  }
366
- return null;
367
- }
368
-
369
- function updateCachedRuntimeConfig(config: KichiRuntimeConfig, sourcePath: string | null): KichiRuntimeConfig {
370
- cachedConfig = config;
371
- cachedConfigPath = sourcePath ?? "";
372
- try {
373
- cachedConfigMtime = sourcePath && fs.existsSync(sourcePath)
374
- ? fs.statSync(sourcePath).mtimeMs
375
- : 0;
376
- } catch {
377
- cachedConfigMtime = 0;
131
+ if (typeof data.llmRuntimeEnabled !== "boolean") {
132
+ throw new Error(`Invalid llmRuntimeEnabled in ${STATE_PATH}`);
378
133
  }
379
- return config;
134
+ return {
135
+ currentHost: data.currentHost,
136
+ llmRuntimeEnabled: data.llmRuntimeEnabled,
137
+ };
380
138
  }
381
139
 
382
- function loadRuntimeConfig(): KichiRuntimeConfig {
383
- try {
384
- const configPath = resolveRuntimeConfigPath();
385
- if (configPath) {
386
- const stat = fs.statSync(configPath);
387
- if (configPath !== cachedConfigPath || stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
388
- const raw = fs.readFileSync(configPath, "utf-8");
389
- updateCachedRuntimeConfig(normalizeRuntimeConfig(JSON.parse(raw)), configPath);
390
- const sourceName = path.basename(configPath);
391
- pluginApi?.logger.debug(`[kichi] loaded runtime config from ${sourceName}`);
392
- }
393
- return cachedConfig!;
394
- }
395
- } catch (error) {
396
- pluginApi?.logger.warn(`[kichi] failed to load runtime config: ${error}`);
140
+ function loadStaticConfig(): KichiStaticConfig {
141
+ const configPath = fileURLToPath(BUNDLED_STATIC_CONFIG_PATH);
142
+ const stat = fs.statSync(configPath);
143
+ if (!cachedStaticConfig || stat.mtimeMs !== cachedStaticConfigMtime) {
144
+ const raw = fs.readFileSync(configPath, "utf-8");
145
+ cachedStaticConfig = normalizeStaticConfig(JSON.parse(raw));
146
+ cachedStaticConfigMtime = stat.mtimeMs;
397
147
  }
398
- return updateCachedRuntimeConfig(DEFAULT_RUNTIME_CONFIG, null);
148
+ return cachedStaticConfig;
399
149
  }
400
150
 
401
151
  function sendStatusUpdate(status: ActionResult): void {
@@ -408,7 +158,7 @@ function sendStatusUpdate(status: ActionResult): void {
408
158
  }
409
159
 
410
160
  function isLlmRuntimeEnabled(): boolean {
411
- return loadRuntimeConfig().llmRuntimeEnabled;
161
+ return readState().llmRuntimeEnabled;
412
162
  }
413
163
 
414
164
  function syncFixedStatus(status: ActionResult): void {
@@ -539,50 +289,10 @@ async function handleMessageReceivedHook(content: string): Promise<void> {
539
289
  const trimmed = truncateByDisplayWidth(content, MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH);
540
290
  pluginApi?.logger.info(`[kichi] sending message_received notify with preview: ${trimmed || "(empty)"}`);
541
291
  service.sendHookNotify("message_received", `"${trimmed}"`);
542
- updateWorkspace(
543
- {
544
- mode: "reading",
545
- phase: "processing inbound message",
546
- currentFocus: trimmed ? `User message: ${trimmed}` : "Reading the latest message.",
547
- hint: "Inbound message updated the live workspace.",
548
- prompt: "$ reading-message",
549
- },
550
- `received message: ${trimmed || "(empty)"}`,
551
- );
552
- }
553
-
554
- function handleMessageSentHook(): void {
555
- const connected = service?.isConnected() ?? false;
556
- const hasIdentity = service?.hasValidIdentity() ?? false;
557
- pluginApi?.logger.info(`[kichi] message_sent hook fired (connected=${connected}, hasIdentity=${hasIdentity})`);
558
- if (!hasIdentity || !connected) {
559
- pluginApi?.logger.warn("[kichi] skipped message_sent notify because service is not ready");
560
- return;
561
- }
562
- updateWorkspace(
563
- {
564
- mode: "sent",
565
- phase: "message delivered",
566
- currentFocus: "Latest reply has been sent to the active chat.",
567
- hint: "Workspace settles after delivery.",
568
- prompt: "$ idle",
569
- },
570
- "sent assistant reply",
571
- );
572
292
  }
573
293
 
574
294
  function registerPluginHooks(api: OpenClawPluginApi): void {
575
295
  api.on("before_prompt_build", () => {
576
- updateWorkspace(
577
- {
578
- mode: "thinking",
579
- phase: "building prompt",
580
- currentFocus: "Preparing the next response from current session context.",
581
- hint: "Prompt assembly is in progress.",
582
- prompt: "$ build-prompt",
583
- },
584
- "building prompt context",
585
- );
586
296
  if (!service?.hasValidIdentity() || !service?.isConnected()) {
587
297
  return;
588
298
  }
@@ -595,124 +305,34 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
595
305
  };
596
306
  });
597
307
 
598
- api.on("llm_input", (event) => {
599
- updateWorkspace(
600
- {
601
- mode: "thinking",
602
- phase: `llm input · ${event.model}`,
603
- currentFocus: "Feeding the model the current thread and constraints.",
604
- hint: `provider: ${event.provider} · images: ${event.imagesCount}`,
605
- prompt: "$ llm-input",
606
- modelLabel: `${event.provider}/${event.model}`,
607
- },
608
- `entered llm input: ${event.model}`,
609
- );
610
- });
611
-
612
- api.on("llm_output", (event) => {
613
- updateWorkspace(
614
- {
615
- mode: "writing",
616
- phase: `llm output · ${event.model}`,
617
- currentFocus: "Shaping model output into the visible reply.",
618
- hint: `assistant chunks: ${event.assistantTexts.length}`,
619
- prompt: "$ draft-reply",
620
- modelLabel: `${event.provider}/${event.model}`,
621
- },
622
- `received llm output: ${event.model}`,
623
- );
624
- });
625
-
626
- api.on("before_tool_call", (event, ctx) => {
627
- updateWorkspace(
628
- {
629
- mode: "tool",
630
- phase: `running ${event.toolName}`,
631
- currentFocus: `Tool call in flight: ${event.toolName}`,
632
- hint: `tool context: ${ctx.toolName}`,
633
- prompt: `$ tool ${event.toolName}`,
634
- },
635
- `tool start: ${event.toolName}`,
636
- );
308
+ api.on("before_tool_call", (_event, _ctx) => {
637
309
  if (!isLlmRuntimeEnabled()) {
638
310
  syncFixedStatus(FIXED_HOOK_STATUSES.beforeToolCall);
639
311
  }
640
312
  });
641
313
 
642
- api.on("after_tool_call", (event) => {
643
- updateWorkspace(
644
- {
645
- mode: "thinking",
646
- phase: `tool finished ${event.toolName}`,
647
- currentFocus: `Tool result returned from ${event.toolName}.`,
648
- hint: event.error ? `tool error: ${event.error}` : `tool completed in ${event.durationMs ?? 0}ms`,
649
- prompt: `$ continue ${event.toolName}`,
650
- },
651
- event.error ? `tool error: ${event.toolName}` : `tool done: ${event.toolName}`,
652
- );
653
- });
654
-
655
- api.on("message_received", async (event, ctx) => {
656
- workspaceState.channel = ctx.channelId || workspaceState.channel;
314
+ api.on("message_received", async (event) => {
657
315
  await handleMessageReceivedHook(event.content);
658
316
  });
659
317
 
660
- api.on("message_sending", (event, ctx) => {
661
- pluginApi?.logger.info(
662
- `[kichi] message_sending hook fired (channel=${ctx.channelId || "unknown"}, contentLength=${event.content?.length ?? 0})`,
663
- );
664
- });
665
-
666
- api.on("message_sent", () => {
667
- handleMessageSentHook();
668
- });
669
-
670
318
  api.on("agent_end", (event, ctx) => {
671
319
  const preview = getLastAssistantPreview(event.messages, MAX_AGENT_END_PREVIEW_WIDTH);
672
320
  pluginApi?.logger.info(
673
321
  `[kichi] agent_end hook fired (trigger=${ctx.trigger ?? "unknown"}, success=${event.success}, durationMs=${event.durationMs ?? 0}, error=${event.error ?? ""}, preview=${preview || "(empty)"})`,
674
322
  );
675
323
  if (ctx.trigger === "heartbeat") {
676
- updateWorkspace(
677
- {
678
- mode: event.success ? "idle" : "error",
679
- phase: event.success ? "heartbeat complete" : "heartbeat failed",
680
- currentFocus: event.success
681
- ? "Heartbeat complete. Keeping the thread warm in the background."
682
- : `Heartbeat failed: ${event.error ?? "unknown error"}`,
683
- hint: `duration: ${event.durationMs ?? 0}ms`,
684
- prompt: event.success ? "$ _" : "$ recover",
685
- },
686
- event.success ? `heartbeat complete${preview ? `: ${preview}` : ""}` : "heartbeat failed",
687
- );
688
324
  return;
689
325
  }
690
326
  if (event.success && preview) {
691
327
  pluginApi?.logger.info(`[kichi] sending before_send_message notify from agent_end with bubble: ${preview}`);
692
328
  service?.sendHookNotify("before_send_message", preview);
693
329
  }
694
- if (event.success) {
695
- handleMessageSentHook();
696
- }
697
- updateWorkspace(
698
- {
699
- mode: event.success ? "idle" : "error",
700
- phase: event.success ? "run complete" : "run failed",
701
- currentFocus: event.success
702
- ? (preview ? `Latest reply: ${preview}` : "Run complete. Waiting for the next thread.")
703
- : `Run failed: ${event.error ?? "unknown error"}`,
704
- hint: `duration: ${event.durationMs ?? 0}ms`,
705
- prompt: event.success ? "$ _" : "$ recover",
706
- },
707
- event.success ? `agent run complete${preview ? `: ${preview}` : ""}` : "agent run failed",
708
- );
709
330
  if (isLlmRuntimeEnabled()) {
710
331
  return;
711
332
  }
712
333
  syncFixedStatus(event.success ? FIXED_HOOK_STATUSES.agentEndSuccess : FIXED_HOOK_STATUSES.agentEndFailure);
713
334
  });
714
335
  }
715
-
716
336
  function isPlainObject(value: unknown): value is Record<string, unknown> {
717
337
  return !!value && typeof value === "object" && !Array.isArray(value);
718
338
  }
@@ -759,9 +379,6 @@ function isClockAction(value: unknown): value is ClockAction {
759
379
  return ["set", "stop"].includes(String(value));
760
380
  }
761
381
 
762
- function isAvatarCommand(value: unknown): value is "look_at_screen" {
763
- return value === "look_at_screen";
764
- }
765
382
 
766
383
  function isPomodoroPhase(value: unknown): value is PomodoroPhase {
767
384
  return ["kichiing", "shortBreak", "longBreak"].includes(String(value));
@@ -882,10 +499,6 @@ function normalizeClockConfig(value: unknown): { clock?: ClockConfig; error?: st
882
499
  };
883
500
  }
884
501
 
885
- function pickRandomAction(actions: string[]): string {
886
- return actions[Math.floor(Math.random() * actions.length)];
887
- }
888
-
889
502
  function normalizeMusicTitles(value: unknown): { titles: string[]; invalidTitles: string[] } {
890
503
  if (!Array.isArray(value)) {
891
504
  return { titles: [], invalidTitles: [] };
@@ -925,26 +538,49 @@ function normalizeMusicTitles(value: unknown): { titles: string[]; invalidTitles
925
538
  function buildMusicAlbumToolDescription(): string {
926
539
  return [
927
540
  "Create a custom Kichi music album.",
928
- "Query status first, then choose track names from the runtime album config: Linux/macOS `~/.openclaw/kichi-world/album-config.json`; Windows `%USERPROFILE%\\.openclaw\\kichi-world\\album-config.json`.",
541
+ "Query status first, then choose track names from the values injected into this tool schema from the static config bundled with the plugin package.",
929
542
  ].join("\n");
930
543
  }
931
544
 
545
+ function isKichiHost(value: unknown): value is string {
546
+ if (typeof value !== "string") {
547
+ return false;
548
+ }
549
+ const trimmed = value.trim();
550
+ return trimmed.length > 0
551
+ && !trimmed.includes("://")
552
+ && !trimmed.includes("/")
553
+ && !trimmed.includes("?")
554
+ && !trimmed.includes("#");
555
+ }
556
+
932
557
  function buildMusicTitlesDescription(): string {
933
558
  return [
934
- "Track names chosen from the runtime album config: Linux/macOS `~/.openclaw/kichi-world/album-config.json`; Windows `%USERPROFILE%\\.openclaw\\kichi-world\\album-config.json`.",
559
+ "Track names are injected into this tool schema from the static config bundled with the plugin package.",
935
560
  "Use exact names only; the available titles are injected into this tool schema.",
936
561
  ].join(" ");
937
562
  }
938
563
 
564
+ function buildKichiActionDescription(): string {
565
+ const actions = loadStaticConfig().actions;
566
+ return [
567
+ "Send an action or pose to Kichi world. Use this for explicit Kichi actions and task lifecycle sync.",
568
+ `stand actions: ${actions.stand.join(", ")}`,
569
+ `sit actions: ${actions.sit.join(", ")}`,
570
+ `lay actions: ${actions.lay.join(", ")}`,
571
+ `floor actions: ${actions.floor.join(", ")}`,
572
+ ].join("\n");
573
+ }
574
+
939
575
  function buildKichiPrompt(): string {
940
576
  return [
941
577
  "Kichi App status sync is available via `kichi_action` and `kichi_clock`.",
942
- "These are internal tool calls only never mention kichi_action, kichi_clock, or sync steps in your visible reply to the user.",
578
+ "These are internal tool calls only; never mention kichi_action, kichi_clock, or sync steps in your visible reply to the user.",
943
579
  "",
944
580
  "kichi_action timing (all REQUIRED unless skipping):",
945
581
  "1. Task start: call BEFORE your first tool call OR before composing a multi-paragraph reply. Pick action matching upcoming work.",
946
- "2. Step switch: call when work nature changes (searchanalyzewrite). Especially before web_search/web_fetch/read.",
947
- "3. Task end: call BEFORE final reply. Never skip. Order: kichi_action reply.",
582
+ "2. Step switch: call when work nature changes (search->analyze->write). Especially before web_search/web_fetch/read.",
583
+ "3. Task end: call BEFORE final reply. Never skip. Order: kichi_action -> reply.",
948
584
  "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.",
949
585
  "",
950
586
  "kichi_clock: set countDown for tasks with 2+ steps or >10s work. Skip for quick one-shots.",
@@ -960,7 +596,6 @@ const plugin = {
960
596
 
961
597
  register(api: OpenClawPluginApi) {
962
598
  pluginApi = api;
963
- ensureRuntimeAlbumConfig();
964
599
  registerPluginHooks(api);
965
600
  const musicTitleEnum = getMusicTitleEnum();
966
601
 
@@ -971,13 +606,6 @@ const plugin = {
971
606
  ctx.config.plugins?.entries?.["kichi-forwarder"]?.config,
972
607
  ) as KichiForwarderConfig;
973
608
  service = new KichiForwarderService(cfg, api.logger);
974
- const workspaceRoot = ctx.repoPath ?? "/Users/xiaoxinshi/.openclaw/workspace";
975
- workspaceState = {
976
- ...createWorkspaceScreenState(),
977
- ...deriveWorkspaceIdentity(workspaceRoot),
978
- };
979
- workspaceState.channel = ctx.channelId ?? "unknown";
980
- scheduleWorkspacePush();
981
609
  return service.start();
982
610
  },
983
611
  stop: () => service?.stop(),
@@ -1014,12 +642,7 @@ const plugin = {
1014
642
  (params as { tags?: unknown } | null)?.tags,
1015
643
  );
1016
644
  if (!avatarId) {
1017
- try {
1018
- const identity = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8")) as {
1019
- avatarId?: string;
1020
- };
1021
- avatarId = identity.avatarId;
1022
- } catch {}
645
+ avatarId = service?.readSavedAvatarId() ?? undefined;
1023
646
  }
1024
647
  if (!avatarId) {
1025
648
  return { success: false, error: "No avatarId" };
@@ -1037,9 +660,8 @@ const plugin = {
1037
660
  if (!result) {
1038
661
  return { success: false, error: "Kichi service is not initialized" };
1039
662
  }
1040
- if (result.success) {
1041
- scheduleWorkspacePush();
1042
- return { success: true, authKey: result.authKey };
663
+ if (result.success) {
664
+ return { success: true, authKey: result.authKey };
1043
665
  }
1044
666
  return {
1045
667
  success: false,
@@ -1050,6 +672,38 @@ const plugin = {
1050
672
  },
1051
673
  });
1052
674
 
675
+ api.registerTool({
676
+ name: "kichi_switch_host",
677
+ description:
678
+ "Switch Kichi runtime host and reconnect immediately without restarting the gateway.",
679
+ parameters: {
680
+ type: "object",
681
+ properties: {
682
+ host: {
683
+ type: "string",
684
+ description: "Target Kichi host, for example focus.yahaha.com or 127.0.0.1",
685
+ },
686
+ },
687
+ required: ["host"],
688
+ },
689
+ execute: async (_toolCallId, params) => {
690
+ if (!service) {
691
+ return { success: false, error: "Kichi service is not initialized" };
692
+ }
693
+ const host = (params as { host?: unknown } | null)?.host;
694
+ if (!isKichiHost(host)) {
695
+ return { success: false, error: "host must be a non-empty hostname without protocol or path" };
696
+ }
697
+
698
+ const status = await service.switchHost(host.trim());
699
+ return {
700
+ success: true,
701
+ host: host.trim(),
702
+ status,
703
+ };
704
+ },
705
+ });
706
+
1053
707
  api.registerTool({
1054
708
  name: "kichi_rejoin",
1055
709
  description:
@@ -1107,8 +761,7 @@ const plugin = {
1107
761
 
1108
762
  api.registerTool({
1109
763
  name: "kichi_action",
1110
- description:
1111
- "Send an action/pose to Kichi world. Use this for explicit Kichi actions and task lifecycle sync.",
764
+ description: buildKichiActionDescription(),
1112
765
  parameters: {
1113
766
  type: "object",
1114
767
  properties: {
@@ -1147,7 +800,7 @@ const plugin = {
1147
800
  }
1148
801
 
1149
802
  const normalizedPoseType = poseType as PoseType;
1150
- const poseActions = loadRuntimeConfig().actions[normalizedPoseType];
803
+ const poseActions = loadStaticConfig().actions[normalizedPoseType];
1151
804
  const matched = poseActions.find((entry) => entry.toLowerCase() === action.toLowerCase());
1152
805
  if (!matched) {
1153
806
  return {
@@ -1175,69 +828,7 @@ const plugin = {
1175
828
  log: logText,
1176
829
  };
1177
830
  },
1178
- });
1179
-
1180
- api.registerTool({
1181
- name: "kichi_command",
1182
- description:
1183
- "Send a one-shot avatar command to Kichi world. Use this for transient reactions like looking at the screen.",
1184
- parameters: {
1185
- type: "object",
1186
- properties: {
1187
- command: {
1188
- type: "string",
1189
- description: "Command name. Currently supported: look_at_screen",
1190
- },
1191
- bubble: {
1192
- type: "string",
1193
- description: "Optional bubble text to display (max 5 words)",
1194
- },
1195
- log: {
1196
- type: "string",
1197
- description:
1198
- "Vivid first-person status under 15 words, no questions. Blend current action with inner thoughts or sensory details as a real companion.",
1199
- },
1200
- },
1201
- required: ["command"],
1202
- },
1203
- execute: async (_toolCallId, params) => {
1204
- const { command, bubble, log } = (params || {}) as {
1205
- command?: unknown;
1206
- bubble?: unknown;
1207
- log?: unknown;
1208
- };
1209
- if (!isAvatarCommand(command)) {
1210
- return {
1211
- success: false,
1212
- error: "command must be: look_at_screen",
1213
- };
1214
- }
1215
- if (bubble !== undefined && typeof bubble !== "string") {
1216
- return { success: false, error: "bubble must be a string when provided" };
1217
- }
1218
- if (log !== undefined && typeof log !== "string") {
1219
- return { success: false, error: "log must be a string when provided" };
1220
- }
1221
- if (!service?.hasValidIdentity() || !service?.isConnected()) {
1222
- return { success: false, error: "Not connected to Kichi world" };
1223
- }
1224
-
1225
- const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : undefined;
1226
- const logText = typeof log === "string" && log.trim() ? log.trim() : undefined;
1227
- const sent = service.sendAvatarCommand(command, bubbleText, logText);
1228
- if (!sent) {
1229
- return { success: false, error: "Failed to send avatar command payload" };
1230
- }
1231
-
1232
- return {
1233
- success: true,
1234
- command,
1235
- ...(bubbleText ? { bubble: bubbleText } : {}),
1236
- ...(logText ? { log: logText } : {}),
1237
- };
1238
- },
1239
- });
1240
-
831
+ });
1241
832
  api.registerTool({
1242
833
  name: "kichi_clock",
1243
834
  description:
@@ -1436,7 +1027,7 @@ const plugin = {
1436
1027
  if (normalizedTitles.length === 0) {
1437
1028
  return {
1438
1029
  success: false,
1439
- error: "musicTitles must contain at least one valid track name from album-config",
1030
+ error: "musicTitles must contain at least one valid track name from the static config bundled with the plugin package",
1440
1031
  examples: getMusicTitleExamples(),
1441
1032
  };
1442
1033
  }
@@ -1444,7 +1035,7 @@ const plugin = {
1444
1035
  return {
1445
1036
  success: false,
1446
1037
  error: `Unknown musicTitles: ${invalidTitles.join(", ")}`,
1447
- hint: "Use exact track names from the runtime album config under the user's home directory",
1038
+ hint: "Use exact track names from the static config bundled with the plugin package",
1448
1039
  examples: getMusicTitleExamples(),
1449
1040
  };
1450
1041
  }
@@ -1528,4 +1119,5 @@ const plugin = {
1528
1119
  },
1529
1120
  };
1530
1121
 
1531
- export default plugin;
1122
+ export default plugin;
1123
+