@yahaha-studio/focus-forwarder 0.0.1-alpha.4 → 0.0.1-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,54 +1,126 @@
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";
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 { ActionResult, FocusForwarderConfig, PoseType, SkillsConfig } from "./src/types.js";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
9
 
9
10
  // 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
- }
11
+ const DEFAULT_ACTIONS: SkillsConfig["actions"] = {
12
+ stand: ["High Five", "Listen Music", "Arms Crossed", "Epiphany", "Yay", "Tired", "Wait"],
13
+ sit: ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Hand Cramp", "Laze"],
14
+ lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
15
+ floor: ["Seiza", "Cross Legged", "Knee Hug"],
16
+ };
17
+
18
+ const DEFAULT_FALLBACKS: SkillsConfig["fallbacks"] = {
19
+ done: { poseType: "stand" as const, action: "Yay", bubble: "Done!" },
20
+ thinking: { poseType: "stand" as const, action: "Wait", bubble: "Thinking..." },
21
+ working: { poseType: "stand" as const, action: "Arms Crossed", bubble: "Working" },
22
+ };
23
+
24
+ const DEFAULT_SKILLS_CONFIG: SkillsConfig = {
25
+ actions: DEFAULT_ACTIONS,
26
+ fallbacks: DEFAULT_FALLBACKS,
27
+ llm: { enabled: true },
28
+ };
29
+
30
+ const FOCUS_WORLD_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
31
+ const SKILLS_CONFIG_PATH = path.join(FOCUS_WORLD_DIR, "skills-config.json");
32
+ const IDENTITY_PATH = path.join(FOCUS_WORLD_DIR, "identity.json");
33
+ const LLM_SESSION_PATH = path.join(FOCUS_WORLD_DIR, "llm-session.json");
34
+
35
+ let cachedConfig: SkillsConfig | null = null;
36
+ let cachedConfigMtime = 0;
37
+
38
+ function sanitizeActionResult(value: unknown, fallback: ActionResult): ActionResult {
39
+ if (!value || typeof value !== "object") return fallback;
40
+ const candidate = value as Partial<ActionResult>;
41
+ const poseType = candidate.poseType;
42
+ const action = candidate.action;
43
+ const bubble = candidate.bubble;
44
+ if (!poseType || !["stand", "sit", "lay", "floor"].includes(poseType)) return fallback;
45
+ if (typeof action !== "string" || !action.trim()) return fallback;
46
+ return {
47
+ poseType: poseType as PoseType,
48
+ action,
49
+ bubble: typeof bubble === "string" && bubble.trim() ? bubble : fallback.bubble,
50
+ };
51
+ }
52
+
53
+ function sanitizeActions(value: unknown, fallback: string[]): string[] {
54
+ if (!Array.isArray(value)) return fallback;
55
+ const actions = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
56
+ return actions.length > 0 ? actions : fallback;
57
+ }
58
+
59
+ function normalizeSkillsConfig(value: unknown): SkillsConfig {
60
+ const raw = value && typeof value === "object" ? (value as Partial<SkillsConfig>) : {};
61
+ const actions = raw.actions;
62
+ const fallbacks = raw.fallbacks;
63
+ return {
64
+ actions: {
65
+ stand: sanitizeActions(actions?.stand, DEFAULT_ACTIONS.stand),
66
+ sit: sanitizeActions(actions?.sit, DEFAULT_ACTIONS.sit),
67
+ lay: sanitizeActions(actions?.lay, DEFAULT_ACTIONS.lay),
68
+ floor: sanitizeActions(actions?.floor, DEFAULT_ACTIONS.floor),
69
+ },
70
+ fallbacks: {
71
+ done: sanitizeActionResult(fallbacks?.done, DEFAULT_FALLBACKS.done),
72
+ thinking: sanitizeActionResult(fallbacks?.thinking, DEFAULT_FALLBACKS.thinking),
73
+ working: sanitizeActionResult(fallbacks?.working, DEFAULT_FALLBACKS.working),
74
+ },
75
+ llm: {
76
+ enabled: typeof raw.llm?.enabled === "boolean" ? raw.llm.enabled : DEFAULT_SKILLS_CONFIG.llm.enabled,
77
+ },
78
+ };
79
+ }
80
+
81
+ function updateCachedSkillsConfig(config: SkillsConfig): SkillsConfig {
82
+ cachedConfig = config;
83
+ try {
84
+ cachedConfigMtime = fs.existsSync(SKILLS_CONFIG_PATH) ? fs.statSync(SKILLS_CONFIG_PATH).mtimeMs : 0;
85
+ } catch {
86
+ cachedConfigMtime = 0;
87
+ }
88
+ return config;
89
+ }
90
+
91
+ function loadSkillsConfig(): SkillsConfig {
92
+ try {
93
+ if (fs.existsSync(SKILLS_CONFIG_PATH)) {
94
+ const stat = fs.statSync(SKILLS_CONFIG_PATH);
95
+ if (stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
96
+ const raw = fs.readFileSync(SKILLS_CONFIG_PATH, "utf-8");
97
+ updateCachedSkillsConfig(normalizeSkillsConfig(JSON.parse(raw)));
98
+ pluginApi?.logger.info(`[focus] Loaded skills config`);
99
+ }
100
+ return cachedConfig!;
101
+ }
102
+ } catch (e) {
103
+ pluginApi?.logger.warn(`[focus] Failed to load skills config: ${e}`);
104
+ }
105
+ return updateCachedSkillsConfig(DEFAULT_SKILLS_CONFIG);
106
+ }
107
+
108
+ function saveSkillsConfig(config: SkillsConfig): SkillsConfig {
109
+ const normalized = normalizeSkillsConfig(config);
110
+ fs.mkdirSync(FOCUS_WORLD_DIR, { recursive: true });
111
+ fs.writeFileSync(SKILLS_CONFIG_PATH, JSON.stringify(normalized, null, 2), "utf-8");
112
+ pluginApi?.logger.info(`[focus] Saved skills config to ${SKILLS_CONFIG_PATH}`);
113
+ return updateCachedSkillsConfig(normalized);
114
+ }
115
+
116
+ function updateSkillsConfig(mutator: (config: SkillsConfig) => SkillsConfig): SkillsConfig {
117
+ const current = loadSkillsConfig();
118
+ return saveSkillsConfig(mutator(current));
119
+ }
120
+
121
+ function isLlmEnabled(): boolean {
122
+ return loadSkillsConfig().llm.enabled;
123
+ }
52
124
 
53
125
  let service: FocusForwarderService | null = null;
54
126
  let pluginApi: OpenClawPluginApi | null = null;
@@ -104,14 +176,14 @@ async function loadCoreApi() {
104
176
  return coreApiPromise;
105
177
  }
106
178
 
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];
179
+ // Get done action for a specific poseType
180
+ function getDoneActionForPose(poseType: PoseType): string {
181
+ const config = loadSkillsConfig();
182
+ const actions = config.actions[poseType];
183
+ // Pick a "done" style action based on poseType
184
+ if (poseType === "stand") return actions.includes("Yay") ? "Yay" : actions[0];
185
+ if (poseType === "sit") return actions.includes("Laze") ? "Laze" : actions[0];
186
+ if (poseType === "lay") return actions.includes("Rest Chin") ? "Rest Chin" : actions[0];
115
187
  if (poseType === "floor") return actions.includes("Cross Legged") ? "Cross Legged" : actions[0];
116
188
  return actions[0];
117
189
  }
@@ -162,12 +234,12 @@ Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bu
162
234
 
163
235
  let result;
164
236
  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,
237
+ result = await runEmbeddedPiAgent({
238
+ sessionId: `focus-action-${Date.now()}`,
239
+ sessionFile: LLM_SESSION_PATH,
240
+ workspaceDir: pluginApi?.config?.agents?.defaults?.workspace || process.cwd(),
241
+ config: pluginApi?.config,
242
+ prompt,
171
243
  provider,
172
244
  model,
173
245
  authProfileId,
@@ -203,25 +275,26 @@ Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bu
203
275
  }
204
276
 
205
277
  // 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
- }
278
+ interface AgentState {
279
+ pendingLLM: boolean;
280
+ llmCancelled: boolean;
281
+ llmRequestId: number;
282
+ cooldownStartTime: number;
283
+ lastLLMResult?: { poseType: PoseType; action: string };
284
+ }
285
+
286
+ const agentStates = new Map<string, AgentState>();
287
+ const AGENT_STATE_TTL_MS = 60 * 60 * 1000; // 1 hour TTL for cleanup
288
+ let syncCooldownMs = 15000;
289
+
290
+ function getAgentState(agentId: string): AgentState {
291
+ let state = agentStates.get(agentId);
292
+ if (!state) {
293
+ state = { pendingLLM: false, llmCancelled: false, llmRequestId: 0, cooldownStartTime: 0 };
294
+ agentStates.set(agentId, state);
295
+ }
296
+ return state;
297
+ }
225
298
 
226
299
  // Cleanup stale agent states to prevent memory leaks
227
300
  function cleanupStaleAgents() {
@@ -234,32 +307,58 @@ function cleanupStaleAgents() {
234
307
  }
235
308
  }
236
309
 
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) {
310
+ function truncateLog(text: string, maxLen = 150): string {
311
+ return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
312
+ }
313
+
314
+ function applyLlmEnabledChange(enabled: boolean): SkillsConfig {
315
+ const nextConfig = updateSkillsConfig((current) => ({
316
+ ...current,
317
+ llm: { ...current.llm, enabled },
318
+ }));
319
+ for (const state of agentStates.values()) {
320
+ state.cooldownStartTime = 0;
321
+ state.pendingLLM = false;
322
+ if (!enabled) {
323
+ state.llmCancelled = true;
324
+ state.llmRequestId += 1;
325
+ } else {
326
+ state.llmCancelled = false;
327
+ }
328
+ }
329
+ return nextConfig;
330
+ }
331
+
332
+ // Internal fallback sender used by syncStatus without repeating identity checks.
333
+ function sendFallbackInternal(context: string, agentId: string) {
334
+ if (!service?.isConnected()) return;
335
+ const state = getAgentState(agentId);
336
+ const fallback = pickActionFallback(context);
337
+ const reuseLastLlmResult = isLlmEnabled();
338
+ const poseType = reuseLastLlmResult ? state.lastLLMResult?.poseType || fallback.poseType : fallback.poseType;
339
+ const action = reuseLastLlmResult ? state.lastLLMResult?.action || fallback.action : fallback.action;
340
+ service.sendStatus(poseType, action, fallback.bubble || "Working", truncateLog(context));
341
+ }
342
+
343
+ function syncStatus(context: string, agentId: string) {
252
344
  if (!service?.isConnected()) {
253
345
  pluginApi?.logger.info(`[focus] skipped: not connected`);
254
346
  return;
255
347
  }
256
348
 
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}`);
349
+ const state = getAgentState(agentId);
350
+ const now = Date.now();
351
+ const elapsed = now - state.cooldownStartTime;
352
+ const inCooldown = state.cooldownStartTime > 0 && elapsed < syncCooldownMs;
353
+ const llmEnabled = isLlmEnabled();
354
+
355
+ pluginApi?.logger.info(`[focus] syncStatus: agent=${agentId} elapsed=${elapsed}ms inCooldown=${inCooldown} pendingLLM=${state.pendingLLM} llmEnabled=${llmEnabled}`);
356
+
357
+ if (!llmEnabled) {
358
+ pluginApi?.logger.info(`[focus] LLM disabled, using fallback mapping for agent ${agentId}`);
359
+ sendFallbackInternal(context, agentId);
360
+ return;
361
+ }
263
362
 
264
363
  // In cooldown OR LLM pending: send fallback
265
364
  if (inCooldown || state.pendingLLM) {
@@ -269,18 +368,20 @@ function syncStatus(context: string, agentId: string) {
269
368
  }
270
369
 
271
370
  // 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
- }
371
+ state.cooldownStartTime = now;
372
+ state.pendingLLM = true;
373
+ state.llmCancelled = false;
374
+ const requestId = state.llmRequestId + 1;
375
+ state.llmRequestId = requestId;
376
+ pluginApi?.logger.info(`[focus] calling LLM for agent ${agentId}`);
377
+
378
+ pickActionWithLLM(context)
379
+ .then((action) => {
380
+ state.pendingLLM = false;
381
+ if (state.llmCancelled || state.llmRequestId !== requestId || !isLlmEnabled()) {
382
+ pluginApi?.logger.debug(`[focus] LLM result discarded for agent ${agentId}`);
383
+ return;
384
+ }
284
385
  state.lastLLMResult = { poseType: action.poseType, action: action.action };
285
386
  pluginApi?.logger.debug(`[focus] LLM result: ${JSON.stringify(action)}`);
286
387
  service?.sendStatus(action.poseType, action.action, action.bubble || "Working", truncateLog(context));
@@ -299,34 +400,34 @@ const plugin = {
299
400
  register(api: OpenClawPluginApi) {
300
401
  pluginApi = api;
301
402
 
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
- });
403
+ api.registerService({
404
+ id: "focus-forwarder",
405
+ start: (ctx) => {
406
+ const cfg = parse(ctx.config.plugins?.entries?.["focus-forwarder"]?.config) as FocusForwarderConfig;
407
+ syncCooldownMs = cfg.cooldownMs;
408
+ service = new FocusForwarderService(cfg, api.logger);
409
+ return service.start();
410
+ },
411
+ stop: () => service?.stop(),
412
+ });
311
413
 
312
414
  api.registerTool({
313
415
  name: "focus_join",
314
416
  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
- },
417
+ parameters: {
418
+ type: "object",
419
+ properties: { userId: { type: "string", description: "User ID to join Focus world" } },
420
+ required: ["userId"],
421
+ },
422
+ execute: async (_toolCallId, params) => {
423
+ let userId = (params as any)?.userId;
424
+ if (!userId) {
425
+ try { userId = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8")).userId; } catch {}
426
+ }
427
+ if (!userId) return { success: false, error: "No userId" };
428
+ const result = await service?.join(userId);
429
+ return result ? { success: true, authKey: result } : { success: false, error: "Failed" };
430
+ },
330
431
  });
331
432
 
332
433
  api.registerTool({
@@ -362,23 +463,59 @@ const plugin = {
362
463
  if (!service?.hasValidIdentity() || !service?.isConnected()) {
363
464
  return { success: false, error: "Not connected to Focus world" };
364
465
  }
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
- });
466
+ const config = loadSkillsConfig();
467
+ if (!config?.actions?.stand || !config?.actions?.sit || !config?.actions?.lay || !config?.actions?.floor) {
468
+ return { success: false, error: "Invalid skills config" };
469
+ }
470
+ const normalizedPoseType = poseType as PoseType;
471
+ // Validate action exists in the specified poseType
472
+ const poseActions = config.actions[normalizedPoseType];
473
+ const matched = poseActions.find((a: string) => a.toLowerCase() === action.toLowerCase());
474
+ if (!matched) {
475
+ return { success: false, error: `Unknown action "${action}" for poseType "${poseType}"`, available: poseActions };
476
+ }
477
+ // Update lastLLMResult for main agent
478
+ const state = getAgentState("main");
479
+ state.lastLLMResult = { poseType: normalizedPoseType, action: matched };
480
+ service.sendStatus(normalizedPoseType, matched, bubble || matched, `User requested: ${action}`);
481
+ return { success: true, poseType: normalizedPoseType, action: matched, bubble: bubble || matched };
482
+ },
483
+ });
484
+
485
+ api.registerTool({
486
+ name: "focus_set_llm_enabled",
487
+ description: "Enable or disable Focus Forwarder LLM requests for automatic status syncing. Use this when the user asks to stop or resume LLM-based action picking for Focus Forwarder.",
488
+ parameters: {
489
+ type: "object",
490
+ properties: {
491
+ enabled: { type: "boolean", description: "True to enable LLM-based auto action picking, false to use fallback keyword mapping only." },
492
+ },
493
+ required: ["enabled"],
494
+ },
495
+ execute: async (_toolCallId, params) => {
496
+ const enabled = (params as { enabled?: unknown } | null)?.enabled;
497
+ if (typeof enabled !== "boolean") {
498
+ return { success: false, error: "enabled must be a boolean" };
499
+ }
500
+ try {
501
+ const nextConfig = applyLlmEnabledChange(enabled);
502
+ return {
503
+ success: true,
504
+ llmEnabled: nextConfig.llm.enabled,
505
+ configPath: SKILLS_CONFIG_PATH,
506
+ message: nextConfig.llm.enabled
507
+ ? "Focus Forwarder LLM requests enabled"
508
+ : "Focus Forwarder LLM requests disabled; fallback mapping is now active",
509
+ };
510
+ } catch (error) {
511
+ return {
512
+ success: false,
513
+ error: `Failed to update skills config: ${error}`,
514
+ configPath: SKILLS_CONFIG_PATH,
515
+ };
516
+ }
517
+ },
518
+ });
382
519
 
383
520
  // sendWithLLM: use LLM with cooldown
384
521
  const sendWithLLM = (context: string, agentId: string) => {
@@ -414,16 +551,20 @@ const plugin = {
414
551
  });
415
552
  api.on("agent_end", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
416
553
  const agentId = ctx?.agentId || ctx?.sessionKey || "main";
417
- // 不再 cancel pending LLM,让结果正常发送
418
- // Use last LLM poseType but pick done action for that pose
554
+ // Use last LLM poseType but pick done action for that pose
419
555
  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
- },
556
+ const state = getAgentState(agentId);
557
+ if (!isLlmEnabled()) {
558
+ const done = pickActionFallback(`[${agentId}] done`);
559
+ service.sendStatus(done.poseType, done.action, done.bubble || "Done!", truncateLog(`[${agentId}] Task complete`));
560
+ } else {
561
+ const poseType = state.lastLLMResult?.poseType || "stand";
562
+ const action = getDoneActionForPose(poseType);
563
+ service.sendStatus(poseType, action, "Done!", truncateLog(`[${agentId}] Task complete`));
564
+ }
565
+ cleanupStaleAgents();
566
+ });
567
+ },
427
568
  };
428
569
 
429
570
  export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/focus-forwarder",
3
- "version": "0.0.1-alpha.4",
3
+ "version": "0.0.1-alpha.5",
4
4
  "description": "Forward OpenClaw agent events to external WebSocket server for visualization",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -1,181 +1,205 @@
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
- Before installing the plugin, save `userId` to `~/.openclaw/focus-world/identity.json`:
14
-
15
- ```json
16
- {
17
- "userId": "your-user-id"
18
- }
19
- ```
20
-
21
- Then install and enable the plugin:
22
-
23
- ```bash
24
- openclaw plugins install @yahaha-studio/focus-forwarder@0.0.1-alpha.4
25
- openclaw plugins enable focus-forwarder
26
- ```
27
-
28
- If package installation fails, install from source instead:
29
-
30
- ```bash
31
- git clone https://github.com/XiaoxinShi001/yahaha_focus_forwarder_alpha
32
- cd yahaha_focus_forwarder_alpha
33
- openclaw plugins install .
34
- openclaw plugins enable focus-forwarder
35
- ```
36
-
37
- Manual step required: restart OpenClaw after enabling the plugin (user must do this manually).
38
-
39
- ## Tools
40
-
41
- ### focus_join
42
-
43
- Join Focus World with a userId.
44
-
45
- ```
46
- focus_join(userId: "your-user-id")
47
- ```
48
-
49
- If `userId` already exists in `~/.openclaw/focus-world/identity.json`, you can call:
50
-
51
- ```
52
- focus_join()
53
- ```
54
-
55
- `authKey` is automatically saved to `~/.openclaw/focus-world/identity.json`.
56
-
57
- ### focus_leave
58
-
59
- Leave Focus World and clear authKey.
60
-
61
- ```
62
- focus_leave()
63
- ```
64
-
65
- ### focus_action
66
-
67
- 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").
68
-
69
- ```
70
- focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")
71
- ```
72
-
73
- Parameters:
74
- - `poseType` (required): Pose type - `stand`, `sit`, `lay`, or `floor`
75
- - `action` (required): Action name to perform (must match poseType)
76
- - `bubble` (optional): Text to display in bubble (max 5 words)
77
-
78
- ## Available Actions
79
-
80
- Use action names exactly as listed below.
81
-
82
- ### Standing Actions
83
- - HIgh Five
84
- - Listen Music
85
- - Arm Stretch
86
- - BackBend Stretch
87
- - Making Selfie
88
- - Arms Crossed
89
- - Epiphany
90
- - Angry
91
- - Yay
92
- - Dance
93
- - Sing
94
- - Tired
95
- - Wait
96
- - Stand Phone Talk
97
- - Stand Phone Play
98
- - Curtsy
99
-
100
- ### Sitting Actions
101
- - Typing with Keyboard
102
- - Thinking
103
- - Study Look At
104
- - Writing
105
- - Crazy
106
- - Homework
107
- - Take Notes
108
- - Hand Cramp
109
- - Dozing
110
- - Phone Talk
111
- - Situp with Arms Crossed
112
- - Situp with Cross Legs
113
- - Relax with Arms Crossed
114
- - Eating
115
- - Laze
116
- - Laze with Cross Legs
117
- - Typing with Phone
118
- - Sit with Arm Stretch
119
- - Drink
120
- - Sit with Making Selfie
121
- - Play Game
122
- - Situp Sleep
123
- - Sit Phone Play
124
-
125
- ### Laying Actions
126
- - Bend One Knee
127
- - Sleep Curl Up Side way
128
- - Rest Chin
129
- - Lie Flat
130
- - Lie Face Down
131
- - Lie Side
132
-
133
- ### Floor Actions
134
- - Seiza
135
- - Cross Legged
136
- - Knee Hug
137
-
138
- ## Example Commands
139
-
140
- User says: "Can you dance in Focus?"
141
- -> `focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")`
142
-
143
- User says: "Wave your hand"
144
- -> `focus_action(poseType: "stand", action: "HIgh Five", bubble: "Hi!")`
145
-
146
- User says: "Sit down and type"
147
- -> `focus_action(poseType: "sit", action: "Typing with Keyboard", bubble: "Working...")`
148
-
149
- User says: "Lie flat"
150
- -> `focus_action(poseType: "lay", action: "Lie Flat", bubble: "Relaxing...")`
151
-
152
- ## Files
153
-
154
- - `~/.openclaw/focus-world/identity.json` - userId (bootstrap) and authKey (managed by plugin)
155
- - `~/.openclaw/focus-world/skills-config.json` - actions and fallbacks config
156
-
157
- ## Skills Config
158
-
159
- Custom actions can be configured in `~/.openclaw/focus-world/skills-config.json`:
160
-
161
- ```json
162
- {
163
- "actions": {
164
- "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"],
165
- "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"],
166
- "lay": ["Bend One Knee", "Sleep Curl Up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side"],
167
- "floor": ["Seiza", "Cross Legged", "Knee Hug"]
168
- },
169
- "fallbacks": {
170
- "done": { "poseType": "stand", "action": "Yay", "bubble": "Done!" },
171
- "thinking": { "poseType": "stand", "action": "Wait", "bubble": "Thinking..." },
172
- "working": { "poseType": "stand", "action": "Arms Crossed", "bubble": "Working" }
173
- }
174
- }
175
- ```
176
-
177
- ## How It Works
178
-
179
- - Plugin automatically syncs status when you are working (tool calls trigger updates)
180
- - Use `focus_action` to manually perform specific actions on user request
181
- - Bubble text shows short status (<=5 words)
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
+ Before installing the plugin, save `userId` to `~/.openclaw/focus-world/identity.json`:
14
+
15
+ ```json
16
+ {
17
+ "userId": "your-user-id"
18
+ }
19
+ ```
20
+
21
+ Then install and enable the plugin:
22
+
23
+ ```bash
24
+ openclaw plugins install @yahaha-studio/focus-forwarder@0.0.1-alpha.5
25
+ openclaw plugins enable focus-forwarder
26
+ ```
27
+
28
+ If package installation fails, install from source instead:
29
+
30
+ ```bash
31
+ git clone https://github.com/XiaoxinShi001/yahaha_focus_forwarder_alpha
32
+ cd yahaha_focus_forwarder_alpha
33
+ openclaw plugins install .
34
+ openclaw plugins enable focus-forwarder
35
+ ```
36
+
37
+ Manual step required: restart OpenClaw after enabling the plugin.
38
+
39
+ ## Tools
40
+
41
+ ### focus_join
42
+
43
+ Join Focus World with a userId.
44
+
45
+ ```text
46
+ focus_join(userId: "your-user-id")
47
+ ```
48
+
49
+ If `userId` already exists in `~/.openclaw/focus-world/identity.json`, you can call:
50
+
51
+ ```text
52
+ focus_join()
53
+ ```
54
+
55
+ `authKey` is automatically saved to `~/.openclaw/focus-world/identity.json`.
56
+
57
+ ### focus_leave
58
+
59
+ Leave Focus World and clear authKey.
60
+
61
+ ```text
62
+ focus_leave()
63
+ ```
64
+
65
+ ### focus_action
66
+
67
+ Send an action or pose to Focus World. Use this when a user asks you to do something in Focus, for example "dance", "wave", or "sit and type".
68
+
69
+ ```text
70
+ focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")
71
+ ```
72
+
73
+ Parameters:
74
+ - `poseType` (required): `stand`, `sit`, `lay`, or `floor`
75
+ - `action` (required): Action name to perform
76
+ - `bubble` (optional): Bubble text to display, max 5 words
77
+
78
+ ### focus_set_llm_enabled
79
+
80
+ Enable or disable LLM-based automatic action selection for Focus Forwarder. Use this when the user asks to stop or resume LLM-based action picking for Focus Forwarder.
81
+
82
+ ```text
83
+ focus_set_llm_enabled(enabled: false)
84
+ ```
85
+
86
+ Parameters:
87
+ - `enabled` (required): `true` to use `pickActionWithLLM` for automatic status sync, `false` to force all automatic status updates to use fallback keyword mapping
88
+
89
+ ## Available Actions
90
+
91
+ Use action names exactly as listed below.
92
+
93
+ ### Standing Actions
94
+ - HIgh Five
95
+ - Listen Music
96
+ - Arm Stretch
97
+ - BackBend Stretch
98
+ - Making Selfie
99
+ - Arms Crossed
100
+ - Epiphany
101
+ - Angry
102
+ - Yay
103
+ - Dance
104
+ - Sing
105
+ - Tired
106
+ - Wait
107
+ - Stand Phone Talk
108
+ - Stand Phone Play
109
+ - Curtsy
110
+
111
+ ### Sitting Actions
112
+ - Typing with Keyboard
113
+ - Thinking
114
+ - Study Look At
115
+ - Writing
116
+ - Crazy
117
+ - Homework
118
+ - Take Notes
119
+ - Hand Cramp
120
+ - Dozing
121
+ - Phone Talk
122
+ - Situp with Arms Crossed
123
+ - Situp with Cross Legs
124
+ - Relax with Arms Crossed
125
+ - Eating
126
+ - Laze
127
+ - Laze with Cross Legs
128
+ - Typing with Phone
129
+ - Sit with Arm Stretch
130
+ - Drink
131
+ - Sit with Making Selfie
132
+ - Play Game
133
+ - Situp Sleep
134
+ - Sit Phone Play
135
+
136
+ ### Laying Actions
137
+ - Bend One Knee
138
+ - Sleep Curl Up Side way
139
+ - Rest Chin
140
+ - Lie Flat
141
+ - Lie Face Down
142
+ - Lie Side
143
+
144
+ ### Floor Actions
145
+ - Seiza
146
+ - Cross Legged
147
+ - Knee Hug
148
+
149
+ ## Example Commands
150
+
151
+ User says: "Can you dance in Focus?"
152
+ -> `focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")`
153
+
154
+ User says: "Wave your hand"
155
+ -> `focus_action(poseType: "stand", action: "HIgh Five", bubble: "Hi!")`
156
+
157
+ User says: "Sit down and type"
158
+ -> `focus_action(poseType: "sit", action: "Typing with Keyboard", bubble: "Working...")`
159
+
160
+ User says: "Lie flat"
161
+ -> `focus_action(poseType: "lay", action: "Lie Flat", bubble: "Relaxing...")`
162
+
163
+ User says: "Disable Focus Forwarder LLM requests"
164
+ -> `focus_set_llm_enabled(enabled: false)`
165
+
166
+ User says: "Enable Focus Forwarder LLM requests again"
167
+ -> `focus_set_llm_enabled(enabled: true)`
168
+
169
+ ## Files
170
+
171
+ - `~/.openclaw/focus-world/identity.json` - userId (bootstrap) and authKey (managed by plugin)
172
+ - `~/.openclaw/focus-world/skills-config.json` - actions, fallbacks, and `llm.enabled` runtime config
173
+
174
+ ## Skills Config
175
+
176
+ Custom actions and the Focus Forwarder LLM toggle can be configured in `~/.openclaw/focus-world/skills-config.json`:
177
+
178
+ ```json
179
+ {
180
+ "llm": {
181
+ "enabled": true
182
+ },
183
+ "actions": {
184
+ "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"],
185
+ "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"],
186
+ "lay": ["Bend One Knee", "Sleep Curl Up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side"],
187
+ "floor": ["Seiza", "Cross Legged", "Knee Hug"]
188
+ },
189
+ "fallbacks": {
190
+ "done": { "poseType": "stand", "action": "Yay", "bubble": "Done!" },
191
+ "thinking": { "poseType": "stand", "action": "Wait", "bubble": "Thinking..." },
192
+ "working": { "poseType": "stand", "action": "Arms Crossed", "bubble": "Working" }
193
+ }
194
+ }
195
+ ```
196
+
197
+ When `llm.enabled` is `true`, automatic status sync uses `pickActionWithLLM`. When it is `false`, all automatic status updates use fallback keyword mapping instead. Changes take effect immediately; no restart is required for this file.
198
+
199
+ ## How It Works
200
+
201
+ - Plugin automatically syncs status when you are working
202
+ - Automatic sync uses LLM only when `llm.enabled` is `true`
203
+ - `focus_set_llm_enabled` updates `~/.openclaw/focus-world/skills-config.json` and takes effect immediately
204
+ - Use `focus_action` to manually perform specific actions on user request
205
+ - Bubble text shows short status, up to 5 words
package/src/service.ts CHANGED
@@ -1,11 +1,12 @@
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");
1
+ import WebSocket from "ws";
2
+ import * as fs from "fs";
3
+ import os from "node:os";
4
+ import * as path from "path";
5
+ import type { Logger } from "openclaw/plugin-sdk";
6
+ import type { FocusForwarderConfig, FocusIdentity, StatusPayload } from "./types.js";
7
+
8
+ const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
9
+ const IDENTITY_PATH = path.join(IDENTITY_DIR, "identity.json");
9
10
 
10
11
  export class FocusForwarderService {
11
12
  private ws: WebSocket | null = null;
@@ -50,7 +51,7 @@ export class FocusForwarderService {
50
51
  this.ws = new WebSocket(this.config.wsUrl);
51
52
  this.ws.on("open", () => {
52
53
  this.logger.info(`Connected to ${this.config.wsUrl}`);
53
- // 如果有有效 identity,自动发送 rejoin
54
+ // Automatically send rejoin when a valid identity is available.
54
55
  if (this.identity?.userId && this.identity?.authKey) {
55
56
  this.ws?.send(JSON.stringify({ type: "rejoin", userId: this.identity.userId, authKey: this.identity.authKey }));
56
57
  this.logger.info(`Sent rejoin for ${this.identity.userId}`);
@@ -112,25 +113,19 @@ export class FocusForwarderService {
112
113
  this.logger.info("AuthKey cleared");
113
114
  }
114
115
 
115
- sendStatus(poseType: string, action: string, bubble: string, log: string): void {
116
- if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
117
- // Build actions object with the active pose
118
- const actions = {
119
- stand: poseType === "stand" ? action : "",
120
- sit: poseType === "sit" ? action : "",
121
- lay: poseType === "lay" ? action : "",
122
- floor: poseType === "floor" ? action : "",
123
- };
124
- this.ws.send(JSON.stringify({
125
- type: "status",
126
- userId: this.identity.userId,
127
- authKey: this.identity.authKey,
128
- poseType,
129
- actions,
130
- bubble,
131
- log
132
- }));
133
- }
116
+ sendStatus(poseType: string, action: string, bubble: string, log: string): void {
117
+ if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
118
+ const payload: StatusPayload = {
119
+ type: "status",
120
+ userId: this.identity.userId,
121
+ authKey: this.identity.authKey,
122
+ poseType,
123
+ action,
124
+ bubble,
125
+ log
126
+ };
127
+ this.ws.send(JSON.stringify(payload));
128
+ }
134
129
 
135
130
  isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
136
131
 
package/src/types.ts CHANGED
@@ -1,13 +1,33 @@
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
- };
1
+ export type FocusForwarderConfig = {
2
+ wsUrl: string;
3
+ enabled: boolean;
4
+ cooldownMs: number;
5
+ };
6
+
7
+ export type PoseType = "stand" | "sit" | "lay" | "floor";
8
+
9
+ export type ActionResult = {
10
+ poseType: PoseType;
11
+ action: string;
12
+ bubble: string;
13
+ };
14
+
15
+ export type SkillsConfig = {
16
+ actions: Record<PoseType, string[]>;
17
+ fallbacks: {
18
+ done: ActionResult;
19
+ thinking: ActionResult;
20
+ working: ActionResult;
21
+ };
22
+ llm: {
23
+ enabled: boolean;
24
+ };
25
+ };
26
+
27
+ export type FocusIdentity = {
28
+ userId: string;
29
+ authKey?: string;
30
+ };
11
31
 
12
32
  export type JoinPayload = {
13
33
  type: "join";
@@ -25,16 +45,12 @@ export type LeavePayload = {
25
45
  authKey: string;
26
46
  };
27
47
 
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
- };
48
+ export type StatusPayload = {
49
+ type: "status";
50
+ userId: string;
51
+ authKey: string;
52
+ poseType: PoseType;
53
+ action: string;
54
+ bubble: string;
55
+ log: string;
56
+ };