@yahaha-studio/focus-forwarder 0.0.1-alpha.0

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 ADDED
@@ -0,0 +1,429 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { parse } from "./src/config.js";
3
+ import { FocusForwarderService } from "./src/service.js";
4
+ import type { FocusForwarderConfig } from "./src/types.js";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+
9
+ // Default actions (fallback when no config file)
10
+ const DEFAULT_ACTIONS = {
11
+ stand: ["High Five", "Listen Music", "Arms Crossed", "Epiphany", "Yay", "Tired", "Wait"],
12
+ sit: ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Hand Cramp", "Laze"],
13
+ lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
14
+ floor: ["Seiza", "Cross Legged", "Knee Hug"],
15
+ };
16
+
17
+ const DEFAULT_FALLBACKS = {
18
+ done: { poseType: "stand" as const, action: "Yay", bubble: "Done!" },
19
+ thinking: { poseType: "stand" as const, action: "Wait", bubble: "Thinking..." },
20
+ working: { poseType: "stand" as const, action: "Arms Crossed", bubble: "Working" },
21
+ };
22
+
23
+ type ActionResult = { poseType: "stand" | "sit" | "lay" | "floor"; action: string; bubble: string };
24
+
25
+ interface SkillsConfig {
26
+ actions: typeof DEFAULT_ACTIONS;
27
+ fallbacks: typeof DEFAULT_FALLBACKS;
28
+ }
29
+
30
+ const SKILLS_CONFIG_PATH = path.join(process.env.HOME || "~", ".openclaw/focus-world/skills-config.json");
31
+
32
+ let cachedConfig: SkillsConfig | null = null;
33
+ let cachedConfigMtime = 0;
34
+
35
+ function loadSkillsConfig(): SkillsConfig {
36
+ try {
37
+ if (fs.existsSync(SKILLS_CONFIG_PATH)) {
38
+ const stat = fs.statSync(SKILLS_CONFIG_PATH);
39
+ if (stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
40
+ const raw = fs.readFileSync(SKILLS_CONFIG_PATH, "utf-8");
41
+ cachedConfig = JSON.parse(raw) as SkillsConfig;
42
+ cachedConfigMtime = stat.mtimeMs;
43
+ pluginApi?.logger.info(`[focus] Loaded skills config`);
44
+ }
45
+ return cachedConfig!;
46
+ }
47
+ } catch (e) {
48
+ pluginApi?.logger.warn(`[focus] Failed to load skills config: ${e}`);
49
+ }
50
+ return { actions: DEFAULT_ACTIONS, fallbacks: DEFAULT_FALLBACKS };
51
+ }
52
+
53
+ let service: FocusForwarderService | null = null;
54
+ let pluginApi: OpenClawPluginApi | null = null;
55
+ let coreApiPromise: Promise<any> | null = null;
56
+
57
+ // Find OpenClaw package root
58
+ function findPackageRoot(startDir: string, name: string): string | null {
59
+ let dir = startDir;
60
+ for (;;) {
61
+ const pkgPath = path.join(dir, "package.json");
62
+ try {
63
+ if (fs.existsSync(pkgPath)) {
64
+ const raw = fs.readFileSync(pkgPath, "utf8");
65
+ const pkg = JSON.parse(raw) as { name?: string };
66
+ if (pkg.name === name) return dir;
67
+ }
68
+ } catch {}
69
+ const parent = path.dirname(dir);
70
+ if (parent === dir) return null;
71
+ dir = parent;
72
+ }
73
+ }
74
+
75
+ function resolveOpenClawRoot(): string {
76
+ const override = process.env.OPENCLAW_ROOT?.trim();
77
+ if (override) return override;
78
+
79
+ const candidates = new Set<string>();
80
+ if (process.argv[1]) candidates.add(path.dirname(process.argv[1]));
81
+ candidates.add(process.cwd());
82
+ try {
83
+ const urlPath = fileURLToPath(import.meta.url);
84
+ candidates.add(path.dirname(urlPath));
85
+ } catch {}
86
+
87
+ for (const start of candidates) {
88
+ const found = findPackageRoot(start, "openclaw");
89
+ if (found) return found;
90
+ }
91
+ throw new Error("Unable to resolve OpenClaw root");
92
+ }
93
+
94
+ // Load core API (same approach as voice-call plugin)
95
+ async function loadCoreApi() {
96
+ if (coreApiPromise) return coreApiPromise;
97
+ coreApiPromise = (async () => {
98
+ const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
99
+ if (!fs.existsSync(distPath)) {
100
+ throw new Error(`Missing extensionAPI.js at ${distPath}`);
101
+ }
102
+ return await import(pathToFileURL(distPath).href);
103
+ })();
104
+ return coreApiPromise;
105
+ }
106
+
107
+ // Get done action for a specific poseType
108
+ function getDoneActionForPose(poseType: string): string {
109
+ const config = loadSkillsConfig();
110
+ const actions = config.actions[poseType as keyof typeof config.actions];
111
+ // Pick a "done" style action based on poseType
112
+ if (poseType === "stand") return actions.includes("Yay") ? "Yay" : actions[0];
113
+ if (poseType === "sit") return actions.includes("Laze") ? "Laze" : actions[0];
114
+ if (poseType === "lay") return actions.includes("Rest Chin") ? "Rest Chin" : actions[0];
115
+ if (poseType === "floor") return actions.includes("Cross Legged") ? "Cross Legged" : actions[0];
116
+ return actions[0];
117
+ }
118
+
119
+ // Fallback action picker (no LLM)
120
+ function pickActionFallback(context: string): ActionResult {
121
+ const config = loadSkillsConfig();
122
+ const fallbacks = config.fallbacks;
123
+ const ctx = context.toLowerCase();
124
+ if (ctx.includes("done") || ctx.includes("finish") || ctx.includes("complete")) {
125
+ return fallbacks.done;
126
+ }
127
+ if (ctx.includes("think") || ctx.includes("start")) {
128
+ return fallbacks.thinking;
129
+ }
130
+ if (ctx.includes("tool") || ctx.includes("exec")) {
131
+ return fallbacks.working;
132
+ }
133
+ return fallbacks.working;
134
+ }
135
+
136
+ // LLM-based action picker using extensionAPI
137
+ async function pickActionWithLLM(context: string): Promise<ActionResult> {
138
+ try {
139
+ const coreApi = await loadCoreApi();
140
+ const { runEmbeddedPiAgent } = coreApi;
141
+
142
+ // Get provider/model from config
143
+ const primary = pluginApi?.config?.agents?.defaults?.model?.primary;
144
+ const provider = typeof primary === "string" ? primary.split("/")[0] : undefined;
145
+ const model = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
146
+
147
+ pluginApi?.logger.info(`[focus] LLM params: provider=${provider} model=${model} primary=${primary}`);
148
+
149
+ // Get auth profile (e.g., "synthetic:default")
150
+ const authProfiles = pluginApi?.config?.auth?.profiles || {};
151
+ const authProfileId = Object.keys(authProfiles).find(k => k.startsWith(provider + ":")) || undefined;
152
+
153
+ const config = loadSkillsConfig();
154
+ const actions = config.actions;
155
+ const prompt = `Pick avatar pose for: "${context}"
156
+ Available poseTypes and actions:
157
+ - stand: ${actions.stand.join(", ")}
158
+ - sit: ${actions.sit.join(", ")}
159
+ - lay: ${actions.lay.join(", ")}
160
+ - floor: ${actions.floor.join(", ")}
161
+ Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bubble":"<5 words>"}`;
162
+
163
+ let result;
164
+ try {
165
+ result = await runEmbeddedPiAgent({
166
+ sessionId: `focus-action-${Date.now()}`,
167
+ sessionFile: path.join(process.env.HOME || "~", ".openclaw/focus-world/llm-session.json"),
168
+ workspaceDir: pluginApi?.config?.agents?.defaults?.workspace || process.cwd(),
169
+ config: pluginApi?.config,
170
+ prompt,
171
+ provider,
172
+ model,
173
+ authProfileId,
174
+ timeoutMs: 10000,
175
+ runId: `focus-${Date.now()}`,
176
+ lane: "focus-llm",
177
+ });
178
+ } catch (llmError) {
179
+ pluginApi?.logger.error(`[focus] runEmbeddedPiAgent error: ${llmError}`);
180
+ return pickActionFallback(context);
181
+ }
182
+
183
+ const text = (result?.payloads || [])
184
+ .filter((p: any) => !p.isError && typeof p.text === "string")
185
+ .map((p: any) => p.text)
186
+ .join("\n")
187
+ .trim();
188
+
189
+ if (!text) return pickActionFallback(context);
190
+ const cleaned = text.replace(/```json?\n?|\n?```/g, "").trim();
191
+ const parsed = JSON.parse(cleaned);
192
+ // Validate new format
193
+ if (parsed.poseType && parsed.action) {
194
+ return parsed as ActionResult;
195
+ }
196
+ // Fallback if LLM returns old format or invalid
197
+ pluginApi?.logger.warn(`[focus] LLM returned invalid format: ${cleaned}`);
198
+ return pickActionFallback(context);
199
+ } catch (e) {
200
+ pluginApi?.logger.warn(`LLM pick failed: ${e}`);
201
+ return pickActionFallback(context);
202
+ }
203
+ }
204
+
205
+ // Per-agent state tracking
206
+ interface AgentState {
207
+ pendingLLM: boolean;
208
+ llmCancelled: boolean;
209
+ cooldownStartTime: number;
210
+ lastLLMResult?: { poseType: string; action: string };
211
+ }
212
+
213
+ const agentStates = new Map<string, AgentState>();
214
+ const SYNC_COOLDOWN_MS = 15000;
215
+ const AGENT_STATE_TTL_MS = 60 * 60 * 1000; // 1 hour TTL for cleanup
216
+
217
+ function getAgentState(agentId: string): AgentState {
218
+ let state = agentStates.get(agentId);
219
+ if (!state) {
220
+ state = { pendingLLM: false, llmCancelled: false, cooldownStartTime: 0 };
221
+ agentStates.set(agentId, state);
222
+ }
223
+ return state;
224
+ }
225
+
226
+ // Cleanup stale agent states to prevent memory leaks
227
+ function cleanupStaleAgents() {
228
+ const now = Date.now();
229
+ for (const [agentId, state] of agentStates) {
230
+ if (!state.pendingLLM && now - state.cooldownStartTime > AGENT_STATE_TTL_MS) {
231
+ agentStates.delete(agentId);
232
+ pluginApi?.logger.debug(`[focus] cleaned up stale agent state: ${agentId}`);
233
+ }
234
+ }
235
+ }
236
+
237
+ function truncateLog(text: string, maxLen = 150): string {
238
+ return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
239
+ }
240
+
241
+ // 内部 fallback 发送(给 syncStatus 用,不检查 identity)
242
+ function sendFallbackInternal(context: string, agentId: string) {
243
+ if (!service?.isConnected()) return;
244
+ const state = getAgentState(agentId);
245
+ const fallback = pickActionFallback(context);
246
+ const poseType = state.lastLLMResult?.poseType || fallback.poseType;
247
+ const action = state.lastLLMResult?.action || fallback.action;
248
+ service.sendStatus(poseType, action, fallback.bubble || "Working", truncateLog(context));
249
+ }
250
+
251
+ function syncStatus(context: string, agentId: string) {
252
+ if (!service?.isConnected()) {
253
+ pluginApi?.logger.info(`[focus] skipped: not connected`);
254
+ return;
255
+ }
256
+
257
+ const state = getAgentState(agentId);
258
+ const now = Date.now();
259
+ const elapsed = now - state.cooldownStartTime;
260
+ const inCooldown = state.cooldownStartTime > 0 && elapsed < SYNC_COOLDOWN_MS;
261
+
262
+ pluginApi?.logger.info(`[focus] syncStatus: agent=${agentId} elapsed=${elapsed}ms inCooldown=${inCooldown} pendingLLM=${state.pendingLLM}`);
263
+
264
+ // In cooldown OR LLM pending: send fallback
265
+ if (inCooldown || state.pendingLLM) {
266
+ pluginApi?.logger.info(`[focus] sending fallback (inCooldown=${inCooldown} pendingLLM=${state.pendingLLM})`);
267
+ sendFallbackInternal(context, agentId);
268
+ return;
269
+ }
270
+
271
+ // Start new LLM request
272
+ state.cooldownStartTime = now;
273
+ state.pendingLLM = true;
274
+ state.llmCancelled = false;
275
+ pluginApi?.logger.info(`[focus] calling LLM for agent ${agentId}`);
276
+
277
+ pickActionWithLLM(context)
278
+ .then((action) => {
279
+ state.pendingLLM = false;
280
+ if (state.llmCancelled) {
281
+ pluginApi?.logger.debug(`[focus] LLM result discarded (cancelled) for agent ${agentId}`);
282
+ return;
283
+ }
284
+ state.lastLLMResult = { poseType: action.poseType, action: action.action };
285
+ pluginApi?.logger.debug(`[focus] LLM result: ${JSON.stringify(action)}`);
286
+ service?.sendStatus(action.poseType, action.action, action.bubble || "Working", truncateLog(context));
287
+ })
288
+ .catch((e) => {
289
+ state.pendingLLM = false;
290
+ pluginApi?.logger.warn(`[focus] LLM failed for agent ${agentId}: ${e}`);
291
+ });
292
+ }
293
+
294
+ const plugin = {
295
+ id: "focus-forwarder",
296
+ name: "Focus Forwarder",
297
+ configSchema: { parse },
298
+
299
+ register(api: OpenClawPluginApi) {
300
+ pluginApi = api;
301
+
302
+ api.registerService({
303
+ id: "focus-forwarder",
304
+ start: (ctx) => {
305
+ const cfg = parse(ctx.config.plugins?.entries?.["focus-forwarder"]?.config) as FocusForwarderConfig;
306
+ service = new FocusForwarderService(cfg, api.logger);
307
+ return service.start();
308
+ },
309
+ stop: () => service?.stop(),
310
+ });
311
+
312
+ api.registerTool({
313
+ name: "focus_join",
314
+ description: "Join Focus world with userId",
315
+ parameters: {
316
+ type: "object",
317
+ properties: { userId: { type: "string", description: "User ID to join Focus world" } },
318
+ required: ["userId"],
319
+ },
320
+ execute: async (_toolCallId, params) => {
321
+ const identityPath = path.join(process.env.HOME || "~", ".openclaw/focus-world/identity.json");
322
+ let userId = (params as any)?.userId;
323
+ if (!userId) {
324
+ try { userId = JSON.parse(fs.readFileSync(identityPath, "utf-8")).userId; } catch {}
325
+ }
326
+ if (!userId) return { success: false, error: "No userId" };
327
+ const result = await service?.join(userId);
328
+ return result ? { success: true, authKey: result } : { success: false, error: "Failed" };
329
+ },
330
+ });
331
+
332
+ api.registerTool({
333
+ name: "focus_leave",
334
+ description: "Leave Focus world",
335
+ parameters: { type: "object", properties: {} },
336
+ execute: async (_toolCallId) => {
337
+ const result = await service?.leave();
338
+ return result ? { success: true } : { success: false, error: "Failed or not connected" };
339
+ },
340
+ });
341
+
342
+ api.registerTool({
343
+ name: "focus_action",
344
+ description: "Send an action/pose to Focus world (e.g., dance, wave, sit, stand)",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
349
+ action: { type: "string", description: "Action name (e.g., High Five, Typing with Keyboard)" },
350
+ bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
351
+ },
352
+ required: ["poseType", "action"],
353
+ },
354
+ execute: async (_toolCallId, params) => {
355
+ const { poseType, action, bubble } = (params || {}) as { poseType?: string; action?: string; bubble?: string };
356
+ if (!poseType || !action) {
357
+ return { success: false, error: "poseType and action parameters are required" };
358
+ }
359
+ if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
360
+ return { success: false, error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor` };
361
+ }
362
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
363
+ return { success: false, error: "Not connected to Focus world" };
364
+ }
365
+ const config = loadSkillsConfig();
366
+ if (!config?.actions?.stand || !config?.actions?.sit || !config?.actions?.lay || !config?.actions?.floor) {
367
+ return { success: false, error: "Invalid skills config" };
368
+ }
369
+ // Validate action exists in the specified poseType
370
+ const poseActions = config.actions[poseType as keyof typeof config.actions];
371
+ const matched = poseActions.find((a: string) => a.toLowerCase() === action.toLowerCase());
372
+ if (!matched) {
373
+ return { success: false, error: `Unknown action "${action}" for poseType "${poseType}"`, available: poseActions };
374
+ }
375
+ // Update lastLLMResult for main agent
376
+ const state = getAgentState("main");
377
+ state.lastLLMResult = { poseType, action: matched };
378
+ service.sendStatus(poseType, matched, bubble || matched, `User requested: ${action}`);
379
+ return { success: true, poseType, action: matched, bubble: bubble || matched };
380
+ },
381
+ });
382
+
383
+ // sendWithLLM: use LLM with cooldown
384
+ const sendWithLLM = (context: string, agentId: string) => {
385
+ pluginApi?.logger.info(`[focus] hook fired: agent=${agentId} context="${context.slice(0, 50)}" hasIdentity=${service?.hasValidIdentity()}`);
386
+ if (service?.hasValidIdentity()) syncStatus(context, agentId);
387
+ };
388
+
389
+ // sendFallback: always use fallback, no LLM (for after_tool_call)
390
+ const sendFallback = (context: string, agentId: string) => {
391
+ pluginApi?.logger.info(`[focus] fallback: agent=${agentId} context="${context.slice(0, 50)}"`);
392
+ if (service?.hasValidIdentity()) sendFallbackInternal(context, agentId);
393
+ };
394
+
395
+ api.on("message_received", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
396
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
397
+ const preview = event.content?.slice(0, 30) || "new message";
398
+ sendWithLLM(`[${agentId}] Received: ${preview}`, agentId);
399
+ });
400
+ api.on("before_agent_start", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
401
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
402
+ const prompt = event?.prompt?.slice(0, 100) || "thinking";
403
+ sendWithLLM(`[${agentId}] Processing: ${prompt}`, agentId);
404
+ });
405
+ api.on("before_tool_call", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
406
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
407
+ pluginApi?.logger.info(`[focus] before_tool_call ctx: ${JSON.stringify(ctx)}`);
408
+ const params = event.params ? JSON.stringify(event.params) : "";
409
+ sendWithLLM(`[${agentId}] Tool: ${event.toolName}${params ? ` ${params}` : ""}`, agentId);
410
+ });
411
+ api.on("after_tool_call", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
412
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
413
+ sendFallback(`[${agentId}] Done: ${event.toolName}`, agentId);
414
+ });
415
+ api.on("agent_end", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
416
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
417
+ // 不再 cancel pending LLM,让结果正常发送
418
+ // Use last LLM poseType but pick done action for that pose
419
+ if (!service?.hasValidIdentity() || !service?.isConnected()) return;
420
+ const state = getAgentState(agentId);
421
+ const poseType = state.lastLLMResult?.poseType || "stand";
422
+ const action = getDoneActionForPose(poseType);
423
+ service.sendStatus(poseType, action, "Done!", truncateLog(`[${agentId}] Task complete`));
424
+ cleanupStaleAgents();
425
+ });
426
+ },
427
+ };
428
+
429
+ export default plugin;
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "focus-forwarder",
3
+ "name": "Focus Forwarder",
4
+ "description": "Forward agent status to Focus world",
5
+ "version": "0.2.1",
6
+ "author": "OpenClaw",
7
+ "skills": ["./skills/focus-forwarder"],
8
+ "configSchema": {
9
+ "type": "object",
10
+ "additionalProperties": false,
11
+ "properties": {
12
+ "wsUrl": {
13
+ "type": "string",
14
+ "default": "ws://127.0.0.1:48870"
15
+ },
16
+ "enabled": {
17
+ "type": "boolean",
18
+ "default": true
19
+ },
20
+ "cooldownMs": {
21
+ "type": "number",
22
+ "default": 15000
23
+ }
24
+ }
25
+ },
26
+ "uiHints": {
27
+ "wsUrl": { "label": "WebSocket URL", "placeholder": "ws://127.0.0.1:48870/ws/openclaw" },
28
+ "enabled": { "label": "Enable Plugin" },
29
+ "cooldownMs": { "label": "Status Cooldown (ms)", "placeholder": "15000" }
30
+ }
31
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@yahaha-studio/focus-forwarder",
3
+ "version": "0.0.1-alpha.0",
4
+ "description": "Forward OpenClaw agent events to external WebSocket server for visualization",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "openclaw": {
8
+ "extensions": [
9
+ "./index.ts"
10
+ ]
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "tag": "alpha"
15
+ },
16
+ "dependencies": {
17
+ "ws": "^8.19.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/ws": "^8.18.1",
21
+ "openclaw": "*"
22
+ }
23
+ }
@@ -0,0 +1,158 @@
1
+ ---
2
+ name: focus-forwarder
3
+ description: Sync OpenClaw agent status to Focus virtual world
4
+ metadata: {"openclaw":{"requires":{"config":["plugins.entries.focus-forwarder.enabled"]},"emoji":"target"}}
5
+ ---
6
+
7
+ # Focus Forwarder
8
+
9
+ Sync your working status to Focus virtual world, and perform actions on command.
10
+
11
+ ## Plugin Setup
12
+
13
+ Install and enable the plugin before using this skill:
14
+
15
+ ```bash
16
+ openclaw plugins install @yahaha-studio/focus-forwarder@0.0.2-alpha.0
17
+ openclaw plugins enable focus-forwarder
18
+ ```
19
+
20
+ Manual step required: restart OpenClaw after enabling the plugin.
21
+
22
+ ## Tools
23
+
24
+ ### focus_join
25
+
26
+ Join Focus World with a userId.
27
+
28
+ ```
29
+ focus_join(userId: "your-user-id")
30
+ ```
31
+
32
+ AuthKey is automatically saved to `~/.openclaw/focus-world/identity.json`.
33
+
34
+ ### focus_leave
35
+
36
+ Leave Focus World and clear authKey.
37
+
38
+ ```
39
+ focus_leave()
40
+ ```
41
+
42
+ ### focus_action
43
+
44
+ Send an action/pose to Focus World. Use this when a user asks you to do something in Focus (for example: "dance", "wave", "sit and type").
45
+
46
+ ```
47
+ focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")
48
+ ```
49
+
50
+ Parameters:
51
+ - `poseType` (required): Pose type - `stand`, `sit`, `lay`, or `floor`
52
+ - `action` (required): Action name to perform (must match poseType)
53
+ - `bubble` (optional): Text to display in bubble (max 5 words)
54
+
55
+ ## Available Actions
56
+
57
+ Use action names exactly as listed below.
58
+
59
+ ### Standing Actions
60
+ - HIgh Five
61
+ - Listen Music
62
+ - Arm Stretch
63
+ - BackBend Stretch
64
+ - Making Selfie
65
+ - Arms Crossed
66
+ - Epiphany
67
+ - Angry
68
+ - Yay
69
+ - Dance
70
+ - Sing
71
+ - Tired
72
+ - Wait
73
+ - Stand Phone Talk
74
+ - Stand Phone Play
75
+ - Curtsy
76
+
77
+ ### Sitting Actions
78
+ - Typing with Keyboard
79
+ - Thinking
80
+ - Study Look At
81
+ - Writing
82
+ - Crazy
83
+ - Homework
84
+ - Take Notes
85
+ - Hand Cramp
86
+ - Dozing
87
+ - Phone Talk
88
+ - Situp with Arms Crossed
89
+ - Situp with Cross Legs
90
+ - Relax with Arms Crossed
91
+ - Eating
92
+ - Laze
93
+ - Laze with Cross Legs
94
+ - Typing with Phone
95
+ - Sit with Arm Stretch
96
+ - Drink
97
+ - Sit with Making Selfie
98
+ - Play Game
99
+ - Situp Sleep
100
+ - Sit Phone Play
101
+
102
+ ### Laying Actions
103
+ - Bend One Knee
104
+ - Sleep Curl Up Side way
105
+ - Rest Chin
106
+ - Lie Flat
107
+ - Lie Face Down
108
+ - Lie Side
109
+
110
+ ### Floor Actions
111
+ - Seiza
112
+ - Cross Legged
113
+ - Knee Hug
114
+
115
+ ## Example Commands
116
+
117
+ User says: "Can you dance in Focus?"
118
+ -> `focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")`
119
+
120
+ User says: "Wave your hand"
121
+ -> `focus_action(poseType: "stand", action: "HIgh Five", bubble: "Hi!")`
122
+
123
+ User says: "Sit down and type"
124
+ -> `focus_action(poseType: "sit", action: "Typing with Keyboard", bubble: "Working...")`
125
+
126
+ User says: "Lie flat"
127
+ -> `focus_action(poseType: "lay", action: "Lie Flat", bubble: "Relaxing...")`
128
+
129
+ ## Files
130
+
131
+ - `~/.openclaw/focus-world/identity.json` - userId and authKey (managed by plugin)
132
+ - `~/.openclaw/focus-world/skills-config.json` - actions and fallbacks config
133
+
134
+ ## Skills Config
135
+
136
+ Custom actions can be configured in `~/.openclaw/focus-world/skills-config.json`:
137
+
138
+ ```json
139
+ {
140
+ "actions": {
141
+ "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"],
142
+ "sit": ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Crazy", "Homework", "Take Notes", "Hand Cramp", "Dozing", "Phone Talk", "Situp with Arms Crossed", "Situp with Cross Legs", "Relax with Arms Crossed", "Eating", "Laze", "Laze with Cross Legs", "Typing with Phone", "Sit with Arm Stretch", "Drink", "Sit with Making Selfie", "Play Game", "Situp Sleep", "Sit Phone Play"],
143
+ "lay": ["Bend One Knee", "Sleep Curl Up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side"],
144
+ "floor": ["Seiza", "Cross Legged", "Knee Hug"]
145
+ },
146
+ "fallbacks": {
147
+ "done": { "poseType": "stand", "action": "Yay", "bubble": "Done!" },
148
+ "thinking": { "poseType": "stand", "action": "Wait", "bubble": "Thinking..." },
149
+ "working": { "poseType": "stand", "action": "Arms Crossed", "bubble": "Working" }
150
+ }
151
+ }
152
+ ```
153
+
154
+ ## How It Works
155
+
156
+ - Plugin automatically syncs status when you are working (tool calls trigger updates)
157
+ - Use `focus_action` to manually perform specific actions on user request
158
+ - Bubble text shows short status (<=5 words)
package/src/config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { FocusForwarderConfig } from "./types.js";
2
+
3
+ export function parse(value: unknown): FocusForwarderConfig {
4
+ const config = (value ?? {}) as Partial<FocusForwarderConfig>;
5
+ return {
6
+ wsUrl: config.wsUrl ?? "ws://127.0.0.1:48870/ws/openclaw",
7
+ enabled: config.enabled ?? true,
8
+ cooldownMs: config.cooldownMs ?? 15000,
9
+ };
10
+ }
package/src/service.ts ADDED
@@ -0,0 +1,144 @@
1
+ import WebSocket from "ws";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import type { Logger } from "openclaw/plugin-sdk";
5
+ import type { FocusForwarderConfig, FocusIdentity, StatusPayload } from "./types.js";
6
+
7
+ const IDENTITY_DIR = path.join(process.env.HOME || "~", ".openclaw/focus-world");
8
+ const IDENTITY_PATH = path.join(IDENTITY_DIR, "identity.json");
9
+
10
+ export class FocusForwarderService {
11
+ private ws: WebSocket | null = null;
12
+ private stopped = false;
13
+ private reconnectTimeout: NodeJS.Timeout | null = null;
14
+ private lastStatusTime = 0;
15
+ private identity: FocusIdentity | null = null;
16
+ private joinResolve: ((authKey: string) => void) | null = null;
17
+
18
+ constructor(private config: FocusForwarderConfig, private logger: Logger) {}
19
+
20
+ async start(): Promise<void> {
21
+ if (!this.config.enabled) return;
22
+ this.identity = this.loadIdentity();
23
+ this.stopped = false;
24
+ this.connect();
25
+ }
26
+
27
+ async stop(): Promise<void> {
28
+ this.stopped = true;
29
+ if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
30
+ this.ws?.close();
31
+ this.ws = null;
32
+ }
33
+
34
+ async join(userId: string): Promise<string | null> {
35
+ return new Promise((resolve) => {
36
+ this.identity = { userId };
37
+ this.joinResolve = resolve;
38
+ const sendJoin = () => this.ws?.send(JSON.stringify({ type: "join", userId }));
39
+ if (this.ws?.readyState === WebSocket.OPEN) {
40
+ sendJoin();
41
+ } else {
42
+ this.ws?.once("open", sendJoin);
43
+ }
44
+ setTimeout(() => { if (this.joinResolve) { this.joinResolve = null; resolve(null); } }, 10000);
45
+ });
46
+ }
47
+
48
+ private connect(): void {
49
+ if (this.stopped) return;
50
+ this.ws = new WebSocket(this.config.wsUrl);
51
+ this.ws.on("open", () => {
52
+ this.logger.info(`Connected to ${this.config.wsUrl}`);
53
+ // 如果有有效 identity,自动发送 rejoin
54
+ if (this.identity?.userId && this.identity?.authKey) {
55
+ this.ws?.send(JSON.stringify({ type: "rejoin", userId: this.identity.userId, authKey: this.identity.authKey }));
56
+ this.logger.info(`Sent rejoin for ${this.identity.userId}`);
57
+ }
58
+ });
59
+ this.ws.on("message", (data) => this.handleMessage(data.toString()));
60
+ this.ws.on("close", () => { this.ws = null; if (!this.stopped) setTimeout(() => this.connect(), 2000); });
61
+ this.ws.on("error", () => {});
62
+ }
63
+
64
+ private handleMessage(data: string): void {
65
+ try {
66
+ const msg = JSON.parse(data);
67
+ if (msg.type === "join_ack" && msg.authKey && this.identity) {
68
+ this.identity.authKey = msg.authKey;
69
+ this.saveIdentity();
70
+ this.logger.info(`Joined as ${this.identity.userId}`);
71
+ this.joinResolve?.(msg.authKey);
72
+ this.joinResolve = null;
73
+ } else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
74
+ // AuthKey invalid/expired, clear it
75
+ this.logger.warn(`Auth failed: ${msg.reason || "unknown"}`);
76
+ this.clearAuthKey();
77
+ } else if (msg.type === "leave_ack") {
78
+ this.logger.info("Left Focus world");
79
+ }
80
+ } catch (e) {
81
+ this.logger.warn(`Failed to parse message: ${e}`);
82
+ }
83
+ }
84
+
85
+ private loadIdentity(): FocusIdentity | null {
86
+ try {
87
+ if (!fs.existsSync(IDENTITY_PATH)) return null;
88
+ const data = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8"));
89
+ // Validate required fields
90
+ if (!data.userId || typeof data.userId !== "string") return null;
91
+ return data;
92
+ } catch (e) {
93
+ this.logger.warn(`Failed to load identity: ${e}`);
94
+ return null;
95
+ }
96
+ }
97
+
98
+ private saveIdentity(): void {
99
+ if (!this.identity?.userId) return;
100
+ try {
101
+ if (!fs.existsSync(IDENTITY_DIR)) fs.mkdirSync(IDENTITY_DIR, { recursive: true, mode: 0o700 });
102
+ fs.writeFileSync(IDENTITY_PATH, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
103
+ } catch (e) {
104
+ this.logger.error(`Failed to save identity: ${e}`);
105
+ }
106
+ }
107
+
108
+ private clearAuthKey(): void {
109
+ if (!this.identity) return;
110
+ this.identity.authKey = undefined;
111
+ this.saveIdentity();
112
+ this.logger.info("AuthKey cleared");
113
+ }
114
+
115
+ sendStatus(actions: StatusPayload["actions"], bubble: string, log: string): void {
116
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
117
+ this.ws.send(JSON.stringify({ type: "status", userId: this.identity.userId, authKey: this.identity.authKey, actions, bubble, log }));
118
+ }
119
+
120
+ isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
121
+
122
+ hasValidIdentity(): boolean { return !!this.identity?.userId && !!this.identity?.authKey; }
123
+
124
+ async leave(): Promise<boolean> {
125
+ if (!this.identity?.userId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
126
+ return new Promise((resolve) => {
127
+ const handler = (data: WebSocket.Data) => {
128
+ try {
129
+ const msg = JSON.parse(data.toString());
130
+ if (msg.type === "leave_ack") {
131
+ this.ws?.off("message", handler);
132
+ this.clearAuthKey();
133
+ resolve(true);
134
+ }
135
+ } catch (e) {
136
+ this.logger.warn(`Failed to parse leave response: ${e}`);
137
+ }
138
+ };
139
+ this.ws!.on("message", handler);
140
+ this.ws!.send(JSON.stringify({ type: "leave", userId: this.identity!.userId, authKey: this.identity!.authKey }));
141
+ setTimeout(() => { this.ws?.off("message", handler); resolve(false); }, 10000);
142
+ });
143
+ }
144
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type FocusForwarderConfig = {
2
+ wsUrl: string;
3
+ enabled: boolean;
4
+ cooldownMs: number;
5
+ };
6
+
7
+ export type FocusIdentity = {
8
+ userId: string;
9
+ authKey?: string;
10
+ };
11
+
12
+ export type JoinPayload = {
13
+ type: "join";
14
+ userId: string;
15
+ };
16
+
17
+ export type JoinAckPayload = {
18
+ type: "join_ack";
19
+ authKey: string;
20
+ };
21
+
22
+ export type LeavePayload = {
23
+ type: "leave";
24
+ userId: string;
25
+ authKey: string;
26
+ };
27
+
28
+ export type StatusPayload = {
29
+ type: "status";
30
+ userId: string;
31
+ authKey: string;
32
+ actions: {
33
+ stand: string;
34
+ sit: string;
35
+ lay: string;
36
+ floor: string;
37
+ };
38
+ bubble: string;
39
+ log: string;
40
+ };