@yahaha-studio/focus-forwarder 0.0.1-alpha.8 → 0.0.1-alpha.9

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,13 +1,12 @@
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
1
  import fs from "node:fs";
6
2
  import os from "node:os";
7
3
  import path from "node:path";
8
4
  import { fileURLToPath, pathToFileURL } from "node:url";
9
-
10
- // Default actions (fallback when no config file)
5
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
+ import { parse } from "./src/config.js";
7
+ import { FocusForwarderService } from "./src/service.js";
8
+ import type { ActionResult, FocusForwarderConfig, PoseType, SkillsConfig } from "./src/types.js";
9
+
11
10
  const DEFAULT_ACTIONS: SkillsConfig["actions"] = {
12
11
  stand: ["High Five", "Listen Music", "Arms Crossed", "Epiphany", "Yay", "Tired", "Wait"],
13
12
  sit: ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Hand Cramp", "Laze"],
@@ -15,16 +14,11 @@ const DEFAULT_ACTIONS: SkillsConfig["actions"] = {
15
14
  floor: ["Seiza", "Cross Legged", "Knee Hug"],
16
15
  };
17
16
 
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
17
  const DEFAULT_SKILLS_CONFIG: SkillsConfig = {
25
18
  actions: DEFAULT_ACTIONS,
26
- fallbacks: DEFAULT_FALLBACKS,
27
- llm: { enabled: true },
19
+ llm: {
20
+ enabled: true,
21
+ },
28
22
  };
29
23
 
30
24
  const FOCUS_WORLD_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
@@ -34,32 +28,24 @@ const LLM_SESSION_PATH = path.join(FOCUS_WORLD_DIR, "llm-session.json");
34
28
 
35
29
  let cachedConfig: SkillsConfig | null = null;
36
30
  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
- }
31
+ let service: FocusForwarderService | null = null;
32
+ let pluginApi: OpenClawPluginApi | null = null;
33
+ let coreApiPromise: Promise<{ runEmbeddedPiAgent?: (params: Record<string, unknown>) => Promise<any> }> | null =
34
+ null;
52
35
 
53
36
  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);
37
+ if (!Array.isArray(value)) {
38
+ return fallback;
39
+ }
40
+ const actions = value.filter(
41
+ (item): item is string => typeof item === "string" && item.trim().length > 0,
42
+ );
56
43
  return actions.length > 0 ? actions : fallback;
57
44
  }
58
45
 
59
46
  function normalizeSkillsConfig(value: unknown): SkillsConfig {
60
47
  const raw = value && typeof value === "object" ? (value as Partial<SkillsConfig>) : {};
61
48
  const actions = raw.actions;
62
- const fallbacks = raw.fallbacks;
63
49
  return {
64
50
  actions: {
65
51
  stand: sanitizeActions(actions?.stand, DEFAULT_ACTIONS.stand),
@@ -67,11 +53,6 @@ function normalizeSkillsConfig(value: unknown): SkillsConfig {
67
53
  lay: sanitizeActions(actions?.lay, DEFAULT_ACTIONS.lay),
68
54
  floor: sanitizeActions(actions?.floor, DEFAULT_ACTIONS.floor),
69
55
  },
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
56
  llm: {
76
57
  enabled: typeof raw.llm?.enabled === "boolean" ? raw.llm.enabled : DEFAULT_SKILLS_CONFIG.llm.enabled,
77
58
  },
@@ -81,7 +62,9 @@ function normalizeSkillsConfig(value: unknown): SkillsConfig {
81
62
  function updateCachedSkillsConfig(config: SkillsConfig): SkillsConfig {
82
63
  cachedConfig = config;
83
64
  try {
84
- cachedConfigMtime = fs.existsSync(SKILLS_CONFIG_PATH) ? fs.statSync(SKILLS_CONFIG_PATH).mtimeMs : 0;
65
+ cachedConfigMtime = fs.existsSync(SKILLS_CONFIG_PATH)
66
+ ? fs.statSync(SKILLS_CONFIG_PATH).mtimeMs
67
+ : 0;
85
68
  } catch {
86
69
  cachedConfigMtime = 0;
87
70
  }
@@ -95,12 +78,12 @@ function loadSkillsConfig(): SkillsConfig {
95
78
  if (stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
96
79
  const raw = fs.readFileSync(SKILLS_CONFIG_PATH, "utf-8");
97
80
  updateCachedSkillsConfig(normalizeSkillsConfig(JSON.parse(raw)));
98
- pluginApi?.logger.debug(`[focus] Loaded skills config`);
81
+ pluginApi?.logger.debug("[focus] loaded skills config");
99
82
  }
100
83
  return cachedConfig!;
101
84
  }
102
- } catch (e) {
103
- pluginApi?.logger.warn(`[focus] Failed to load skills config: ${e}`);
85
+ } catch (error) {
86
+ pluginApi?.logger.warn(`[focus] failed to load skills config: ${error}`);
104
87
  }
105
88
  return updateCachedSkillsConfig(DEFAULT_SKILLS_CONFIG);
106
89
  }
@@ -109,413 +92,424 @@ function saveSkillsConfig(config: SkillsConfig): SkillsConfig {
109
92
  const normalized = normalizeSkillsConfig(config);
110
93
  fs.mkdirSync(FOCUS_WORLD_DIR, { recursive: true });
111
94
  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
95
  return updateCachedSkillsConfig(normalized);
114
96
  }
115
97
 
116
98
  function updateSkillsConfig(mutator: (config: SkillsConfig) => SkillsConfig): SkillsConfig {
117
- const current = loadSkillsConfig();
118
- return saveSkillsConfig(mutator(current));
99
+ return saveSkillsConfig(mutator(loadSkillsConfig()));
119
100
  }
120
101
 
121
102
  function isLlmEnabled(): boolean {
122
103
  return loadSkillsConfig().llm.enabled;
123
104
  }
124
-
125
- let service: FocusForwarderService | null = null;
126
- let pluginApi: OpenClawPluginApi | null = null;
127
- let coreApiPromise: Promise<any> | null = null;
128
-
129
- // Find OpenClaw package root
130
- function findPackageRoot(startDir: string, name: string): string | null {
131
- let dir = startDir;
132
- for (;;) {
133
- const pkgPath = path.join(dir, "package.json");
134
- try {
135
- if (fs.existsSync(pkgPath)) {
136
- const raw = fs.readFileSync(pkgPath, "utf8");
137
- const pkg = JSON.parse(raw) as { name?: string };
138
- if (pkg.name === name) return dir;
139
- }
140
- } catch {}
141
- const parent = path.dirname(dir);
142
- if (parent === dir) return null;
143
- dir = parent;
144
- }
145
- }
146
-
147
- function resolveOpenClawRoot(): string {
148
- const override = process.env.OPENCLAW_ROOT?.trim();
149
- if (override) return override;
150
-
151
- const candidates = new Set<string>();
152
- if (process.argv[1]) candidates.add(path.dirname(process.argv[1]));
153
- candidates.add(process.cwd());
154
- try {
155
- const urlPath = fileURLToPath(import.meta.url);
156
- candidates.add(path.dirname(urlPath));
157
- } catch {}
158
-
159
- for (const start of candidates) {
160
- const found = findPackageRoot(start, "openclaw");
161
- if (found) return found;
162
- }
163
- throw new Error("Unable to resolve OpenClaw root");
164
- }
165
-
166
- // Load core API (same approach as voice-call plugin)
167
- async function loadCoreApi() {
168
- if (coreApiPromise) return coreApiPromise;
169
- coreApiPromise = (async () => {
170
- const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
171
- if (!fs.existsSync(distPath)) {
172
- throw new Error(`Missing extensionAPI.js at ${distPath}`);
173
- }
174
- return await import(pathToFileURL(distPath).href);
175
- })();
176
- return coreApiPromise;
177
- }
178
-
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];
187
- if (poseType === "floor") return actions.includes("Cross Legged") ? "Cross Legged" : actions[0];
188
- return actions[0];
189
- }
190
-
191
- // Fallback action picker (no LLM)
192
- function pickActionFallback(context: string): ActionResult {
193
- const config = loadSkillsConfig();
194
- const fallbacks = config.fallbacks;
195
- const ctx = context.toLowerCase();
196
- if (ctx.includes("done") || ctx.includes("finish") || ctx.includes("complete")) {
197
- return fallbacks.done;
198
- }
199
- if (ctx.includes("think") || ctx.includes("start")) {
200
- return fallbacks.thinking;
201
- }
202
- if (ctx.includes("tool") || ctx.includes("exec")) {
203
- return fallbacks.working;
204
- }
205
- return fallbacks.working;
206
- }
207
-
208
- // LLM-based action picker using extensionAPI
209
- async function pickActionWithLLM(context: string): Promise<ActionResult> {
210
- try {
211
- const coreApi = await loadCoreApi();
212
- const { runEmbeddedPiAgent } = coreApi;
213
-
214
- // Get provider/model from config
215
- const primary = pluginApi?.config?.agents?.defaults?.model?.primary;
216
- const provider = typeof primary === "string" ? primary.split("/")[0] : undefined;
217
- const model = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
218
-
219
- pluginApi?.logger.debug(`[focus] LLM params: provider=${provider} model=${model} primary=${primary}`);
220
-
221
- // Get auth profile (e.g., "synthetic:default")
222
- const authProfiles = pluginApi?.config?.auth?.profiles || {};
223
- const authProfileId = Object.keys(authProfiles).find(k => k.startsWith(provider + ":")) || undefined;
224
-
225
- const config = loadSkillsConfig();
226
- const actions = config.actions;
227
- const prompt = `Pick avatar pose for: "${context}"
228
- Available poseTypes and actions:
229
- - stand: ${actions.stand.join(", ")}
230
- - sit: ${actions.sit.join(", ")}
231
- - lay: ${actions.lay.join(", ")}
232
- - floor: ${actions.floor.join(", ")}
233
- Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bubble":"<5 words>"}`;
234
-
235
- let result;
236
- try {
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,
243
- provider,
244
- model,
245
- authProfileId,
246
- timeoutMs: 10000,
247
- runId: `focus-${Date.now()}`,
248
- lane: "focus-llm",
249
- });
250
- } catch (llmError) {
251
- pluginApi?.logger.error(`[focus] runEmbeddedPiAgent error: ${llmError}`);
252
- return pickActionFallback(context);
253
- }
254
-
255
- const text = (result?.payloads || [])
256
- .filter((p: any) => !p.isError && typeof p.text === "string")
257
- .map((p: any) => p.text)
258
- .join("\n")
259
- .trim();
260
-
261
- if (!text) return pickActionFallback(context);
262
- const cleaned = text.replace(/```json?\n?|\n?```/g, "").trim();
263
- const parsed = JSON.parse(cleaned);
264
- // Validate new format
265
- if (parsed.poseType && parsed.action) {
266
- return parsed as ActionResult;
267
- }
268
- // Fallback if LLM returns old format or invalid
269
- pluginApi?.logger.warn(`[focus] LLM returned invalid format: ${cleaned}`);
270
- return pickActionFallback(context);
271
- } catch (e) {
272
- pluginApi?.logger.warn(`LLM pick failed: ${e}`);
273
- return pickActionFallback(context);
274
- }
275
- }
276
-
277
- // Per-agent state tracking
278
- interface AgentState {
279
- pendingLLM: boolean;
280
- llmCancelled: boolean;
281
- llmRequestId: number;
282
- cooldownActive: boolean;
283
- cooldownStartTime: number;
284
- cooldownTimer?: ReturnType<typeof setTimeout>;
285
- lastLLMResult?: { poseType: PoseType; action: string };
286
- }
287
-
288
- const agentStates = new Map<string, AgentState>();
289
- const AGENT_STATE_TTL_MS = 60 * 60 * 1000; // 1 hour TTL for cleanup
290
- let syncCooldownMs = 15000;
291
-
292
- function getAgentState(agentId: string): AgentState {
293
- let state = agentStates.get(agentId);
294
- if (!state) {
295
- state = {
296
- pendingLLM: false,
297
- llmCancelled: false,
298
- llmRequestId: 0,
299
- cooldownActive: false,
300
- cooldownStartTime: 0,
301
- };
302
- agentStates.set(agentId, state);
105
+
106
+ function findPackageRoot(startDir: string, name: string): string | null {
107
+ let dir = startDir;
108
+ for (;;) {
109
+ const packageJsonPath = path.join(dir, "package.json");
110
+ try {
111
+ if (fs.existsSync(packageJsonPath)) {
112
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { name?: string };
113
+ if (pkg.name === name) {
114
+ return dir;
115
+ }
116
+ }
117
+ } catch {}
118
+ const parent = path.dirname(dir);
119
+ if (parent === dir) {
120
+ return null;
121
+ }
122
+ dir = parent;
303
123
  }
304
- return state;
305
124
  }
306
125
 
307
- function startCooldown(state: AgentState, agentId: string, startTime: number) {
308
- if (state.cooldownTimer) clearTimeout(state.cooldownTimer);
309
- state.cooldownActive = true;
310
- state.cooldownStartTime = startTime;
311
- state.cooldownTimer = setTimeout(() => {
312
- state.cooldownActive = false;
313
- state.cooldownTimer = undefined;
314
- pluginApi?.logger.debug(`[focus] cooldown ended for agent ${agentId}`);
315
- }, syncCooldownMs);
316
- }
126
+ function resolveOpenClawRoot(): string {
127
+ const override = process.env.OPENCLAW_ROOT?.trim();
128
+ if (override) {
129
+ return override;
130
+ }
131
+
132
+ const candidates = new Set<string>();
133
+ if (process.argv[1]) {
134
+ candidates.add(path.dirname(process.argv[1]));
135
+ }
136
+ candidates.add(process.cwd());
137
+ try {
138
+ candidates.add(path.dirname(fileURLToPath(import.meta.url)));
139
+ } catch {}
317
140
 
318
- // Cleanup stale agent states to prevent memory leaks
319
- function cleanupStaleAgents() {
320
- const now = Date.now();
321
- for (const [agentId, state] of agentStates) {
322
- if (!state.pendingLLM && now - state.cooldownStartTime > AGENT_STATE_TTL_MS) {
323
- if (state.cooldownTimer) clearTimeout(state.cooldownTimer);
324
- agentStates.delete(agentId);
325
- pluginApi?.logger.debug(`[focus] cleaned up stale agent state: ${agentId}`);
141
+ for (const start of candidates) {
142
+ const found = findPackageRoot(start, "openclaw");
143
+ if (found) {
144
+ return found;
326
145
  }
327
146
  }
147
+
148
+ throw new Error("Unable to resolve OpenClaw root");
149
+ }
150
+
151
+ async function loadCoreApi(): Promise<{
152
+ runEmbeddedPiAgent?: (params: Record<string, unknown>) => Promise<any>;
153
+ }> {
154
+ if (!coreApiPromise) {
155
+ coreApiPromise = (async () => {
156
+ const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
157
+ if (!fs.existsSync(distPath)) {
158
+ throw new Error(`Missing extensionAPI.js at ${distPath}`);
159
+ }
160
+ return (await import(pathToFileURL(distPath).href)) as {
161
+ runEmbeddedPiAgent?: (params: Record<string, unknown>) => Promise<any>;
162
+ };
163
+ })();
164
+ }
165
+ return coreApiPromise;
328
166
  }
329
-
167
+
330
168
  function truncateLog(text: string, maxLen = 150): string {
331
- return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
169
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
332
170
  }
333
171
 
334
- function applyLlmEnabledChange(enabled: boolean): SkillsConfig {
335
- const nextConfig = updateSkillsConfig((current) => ({
336
- ...current,
337
- llm: { ...current.llm, enabled },
338
- }));
339
- pluginApi?.logger.info(`[focus] Automatic LLM action picking ${enabled ? "enabled" : "disabled"}`);
340
- for (const state of agentStates.values()) {
341
- state.pendingLLM = false;
342
- if (!enabled) {
343
- state.llmCancelled = true;
344
- state.llmRequestId += 1;
345
- } else {
346
- state.llmCancelled = false;
347
- }
172
+ function pickRandomAction(actions: string[]): string {
173
+ return actions[Math.floor(Math.random() * actions.length)];
174
+ }
175
+
176
+ function buildFallbackCandidates(context: string): Record<PoseType, string[]> {
177
+ const lowerContext = context.toLowerCase();
178
+ if (
179
+ lowerContext.includes("sleep") ||
180
+ lowerContext.includes("rest") ||
181
+ lowerContext.includes("lie") ||
182
+ lowerContext.includes("nap")
183
+ ) {
184
+ return {
185
+ stand: [],
186
+ sit: [],
187
+ lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
188
+ floor: [],
189
+ };
348
190
  }
349
- return nextConfig;
191
+
192
+ if (
193
+ lowerContext.includes("sit") ||
194
+ lowerContext.includes("write") ||
195
+ lowerContext.includes("typing") ||
196
+ lowerContext.includes("study") ||
197
+ lowerContext.includes("think") ||
198
+ lowerContext.includes("work")
199
+ ) {
200
+ return {
201
+ stand: [],
202
+ sit: ["Typing with Keyboard", "Writing", "Thinking", "Study Look At", "Hand Cramp"],
203
+ lay: [],
204
+ floor: [],
205
+ };
206
+ }
207
+
208
+ return {
209
+ stand: ["Wait", "Arms Crossed", "Epiphany", "Tired"],
210
+ sit: ["Typing with Keyboard", "Thinking"],
211
+ lay: [],
212
+ floor: [],
213
+ };
350
214
  }
351
-
352
- // Internal fallback sender used by syncStatus without repeating identity checks.
353
- function sendFallbackInternal(context: string, agentId: string) {
354
- if (!service?.isConnected()) return;
355
- const state = getAgentState(agentId);
356
- const fallback = pickActionFallback(context);
357
- const reuseLastLlmResult = isLlmEnabled();
358
- const poseType = reuseLastLlmResult ? state.lastLLMResult?.poseType || fallback.poseType : fallback.poseType;
359
- const action = reuseLastLlmResult ? state.lastLLMResult?.action || fallback.action : fallback.action;
360
- service.sendStatus(poseType, action, fallback.bubble || "Working", truncateLog(context));
215
+
216
+ function buildMessageFallbackStatus(context: string): ActionResult {
217
+ const config = loadSkillsConfig();
218
+ const candidates = buildFallbackCandidates(context);
219
+ const poseOrder: PoseType[] = ["sit", "stand", "lay", "floor"];
220
+ const poseType =
221
+ poseOrder.find((pose) => {
222
+ const preferred = candidates[pose];
223
+ if (preferred.length === 0) {
224
+ return false;
225
+ }
226
+ return config.actions[pose].some((action) =>
227
+ preferred.some((candidate) => candidate.toLowerCase() === action.toLowerCase()),
228
+ );
229
+ }) ?? "stand";
230
+
231
+ const preferredPool = candidates[poseType];
232
+ const availableActions = config.actions[poseType];
233
+ const actionPool =
234
+ preferredPool.length > 0
235
+ ? availableActions.filter((action) =>
236
+ preferredPool.some((candidate) => candidate.toLowerCase() === action.toLowerCase()),
237
+ )
238
+ : availableActions;
239
+ const action = pickRandomAction(actionPool.length > 0 ? actionPool : availableActions);
240
+
241
+ return {
242
+ poseType,
243
+ action,
244
+ bubble: poseType === "sit" ? "Working" : poseType === "lay" ? "Resting" : "Thinking",
245
+ };
361
246
  }
362
247
 
363
- function syncStatus(context: string, agentId: string) {
364
- if (!service?.isConnected()) {
365
- pluginApi?.logger.debug(`[focus] skipped: not connected`);
366
- return;
367
- }
368
-
369
- const state = getAgentState(agentId);
370
- const now = Date.now();
371
- const elapsed = state.cooldownStartTime > 0 ? now - state.cooldownStartTime : 0;
372
- if (state.cooldownActive && elapsed >= syncCooldownMs) {
373
- state.cooldownActive = false;
374
- if (state.cooldownTimer) {
375
- clearTimeout(state.cooldownTimer);
376
- state.cooldownTimer = undefined;
248
+ async function pickActionWithLlm(context: string): Promise<ActionResult> {
249
+ const fallback = buildMessageFallbackStatus(context);
250
+
251
+ try {
252
+ const coreApi = await loadCoreApi();
253
+ const runEmbeddedPiAgent = coreApi.runEmbeddedPiAgent;
254
+ if (typeof runEmbeddedPiAgent !== "function") {
255
+ throw new Error("runEmbeddedPiAgent is unavailable");
256
+ }
257
+
258
+ const primary = pluginApi?.config?.agents?.defaults?.model?.primary;
259
+ const provider = typeof primary === "string" ? primary.split("/")[0] : undefined;
260
+ const model = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
261
+ const authProfiles = pluginApi?.config?.auth?.profiles ?? {};
262
+ const authProfileId =
263
+ provider && typeof authProfiles === "object"
264
+ ? Object.keys(authProfiles).find((key) => key.startsWith(`${provider}:`))
265
+ : undefined;
266
+
267
+ const actions = loadSkillsConfig().actions;
268
+ const prompt = `Pick avatar pose for: "${context}"
269
+ Available poseTypes and actions:
270
+ - stand: ${actions.stand.join(", ")}
271
+ - sit: ${actions.sit.join(", ")}
272
+ - lay: ${actions.lay.join(", ")}
273
+ - floor: ${actions.floor.join(", ")}
274
+ Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bubble":"<5 words>"}`;
275
+
276
+ const result = await runEmbeddedPiAgent({
277
+ sessionId: `focus-action-${Date.now()}`,
278
+ sessionFile: LLM_SESSION_PATH,
279
+ workspaceDir: pluginApi?.config?.agents?.defaults?.workspace ?? process.cwd(),
280
+ config: pluginApi?.config,
281
+ prompt,
282
+ provider,
283
+ model,
284
+ authProfileId,
285
+ timeoutMs: 10000,
286
+ runId: `focus-${Date.now()}`,
287
+ lane: "focus-llm",
288
+ });
289
+
290
+ const text = (result?.payloads ?? [])
291
+ .filter((payload: any) => !payload?.isError && typeof payload?.text === "string")
292
+ .map((payload: any) => payload.text)
293
+ .join("\n")
294
+ .trim();
295
+
296
+ if (!text) {
297
+ return fallback;
298
+ }
299
+
300
+ const cleaned = text.replace(/```json?\n?|\n?```/g, "").trim();
301
+ const parsed = JSON.parse(cleaned) as Partial<ActionResult>;
302
+ if (!parsed.poseType || !parsed.action) {
303
+ pluginApi?.logger.warn(`[focus] invalid LLM status payload: ${cleaned}`);
304
+ return fallback;
377
305
  }
306
+ if (!["stand", "sit", "lay", "floor"].includes(parsed.poseType)) {
307
+ pluginApi?.logger.warn(`[focus] invalid poseType from LLM: ${parsed.poseType}`);
308
+ return fallback;
309
+ }
310
+
311
+ const poseType = parsed.poseType as PoseType;
312
+ const matchedAction = loadSkillsConfig().actions[poseType].find(
313
+ (entry) => entry.toLowerCase() === parsed.action?.toLowerCase(),
314
+ );
315
+ if (!matchedAction) {
316
+ pluginApi?.logger.warn(`[focus] unknown action from LLM: ${parsed.action}`);
317
+ return fallback;
318
+ }
319
+
320
+ return {
321
+ poseType,
322
+ action: matchedAction,
323
+ bubble:
324
+ typeof parsed.bubble === "string" && parsed.bubble.trim() ? parsed.bubble.trim() : matchedAction,
325
+ };
326
+ } catch (error) {
327
+ pluginApi?.logger.warn(`[focus] failed to pick action with LLM: ${error}`);
328
+ return fallback;
378
329
  }
379
- const inCooldown = state.cooldownActive;
380
- const llmEnabled = isLlmEnabled();
381
-
382
- pluginApi?.logger.debug(`[focus] syncStatus: agent=${agentId} elapsed=${elapsed}ms inCooldown=${inCooldown} pendingLLM=${state.pendingLLM} llmEnabled=${llmEnabled}`);
383
-
384
- if (!llmEnabled) {
385
- pluginApi?.logger.debug(`[focus] LLM disabled, using fallback mapping for agent ${agentId}`);
386
- sendFallbackInternal(context, agentId);
387
- return;
330
+ }
331
+
332
+ function pickPreferredAction(poseType: PoseType, preferred: string[]): string {
333
+ const actions = loadSkillsConfig().actions[poseType];
334
+ for (const candidate of preferred) {
335
+ const matched = actions.find((action) => action.toLowerCase() === candidate.toLowerCase());
336
+ if (matched) {
337
+ return matched;
338
+ }
388
339
  }
389
-
390
- // In cooldown OR LLM pending: send fallback
391
- if (inCooldown || state.pendingLLM) {
392
- pluginApi?.logger.debug(`[focus] sending fallback (inCooldown=${inCooldown} pendingLLM=${state.pendingLLM})`);
393
- sendFallbackInternal(context, agentId);
394
- return;
395
- }
396
-
397
- // Start new LLM request
398
- startCooldown(state, agentId, now);
399
- state.pendingLLM = true;
400
- state.llmCancelled = false;
401
- const requestId = state.llmRequestId + 1;
402
- state.llmRequestId = requestId;
403
- pluginApi?.logger.debug(`[focus] calling LLM for agent ${agentId}`);
404
-
405
- pickActionWithLLM(context)
406
- .then((action) => {
407
- state.pendingLLM = false;
408
- if (state.llmCancelled || state.llmRequestId !== requestId || !isLlmEnabled()) {
409
- pluginApi?.logger.debug(`[focus] LLM result discarded for agent ${agentId}`);
410
- return;
411
- }
412
- state.lastLLMResult = { poseType: action.poseType, action: action.action };
413
- pluginApi?.logger.debug(`[focus] LLM result: ${JSON.stringify(action)}`);
414
- service?.sendStatus(action.poseType, action.action, action.bubble || "Working", truncateLog(context));
415
- })
416
- .catch((e) => {
417
- state.pendingLLM = false;
418
- pluginApi?.logger.warn(`[focus] LLM failed for agent ${agentId}: ${e}`);
419
- });
420
- }
421
-
422
- const plugin = {
423
- id: "focus-forwarder",
424
- name: "Focus Forwarder",
425
- configSchema: { parse },
426
-
427
- register(api: OpenClawPluginApi) {
428
- pluginApi = api;
429
-
340
+ return actions[0];
341
+ }
342
+
343
+ function buildLifecycleStatus(
344
+ poseType: PoseType,
345
+ preferred: string[],
346
+ bubble: string,
347
+ ): ActionResult {
348
+ return {
349
+ poseType,
350
+ action: pickPreferredAction(poseType, preferred),
351
+ bubble,
352
+ };
353
+ }
354
+
355
+ function resolveLifecycleStatuses(): {
356
+ start: ActionResult;
357
+ working: ActionResult;
358
+ done: ActionResult;
359
+ } {
360
+ return {
361
+ start: buildLifecycleStatus("stand", ["Wait", "Arms Crossed", "Epiphany"], "Starting"),
362
+ working: buildLifecycleStatus(
363
+ "sit",
364
+ ["Typing with Keyboard", "Writing", "Thinking", "Study Look At"],
365
+ "Working",
366
+ ),
367
+ done: buildLifecycleStatus("stand", ["Yay", "High Five", "Epiphany"], "Done!"),
368
+ };
369
+ }
370
+
371
+ function formatFocusActionCall(status: ActionResult): string {
372
+ return `focus_action(poseType: "${status.poseType}", action: "${status.action}", bubble: "${status.bubble}")`;
373
+ }
374
+
375
+ function buildFocusPrompt(): string {
376
+ const { start, working, done } = resolveLifecycleStatuses();
377
+ return [
378
+ "Focus App status sync is mandatory for substantive work in this turn.",
379
+ "Use the `focus_action` tool to sync your task lifecycle to Focus App.",
380
+ `- Before starting the task or before your first non-Focus tool call, call ${formatFocusActionCall(start)}.`,
381
+ `- When active execution begins, call ${formatFocusActionCall(working)} once for the working phase.`,
382
+ `- After the task is complete and immediately before your final user-visible reply, call ${formatFocusActionCall(done)}.`,
383
+ "Rules:",
384
+ "- Do not spam `focus_action` on every tool call; once per phase per turn is enough.",
385
+ "- If the user explicitly asks for a different Focus pose/action, follow the user's request instead of the default phase action.",
386
+ "- Skip the lifecycle sync only if the user explicitly says not to sync Focus, or if `focus_action` already failed in this turn.",
387
+ "- Do not add lifecycle sync calls when the task itself is only about `focus_join`, `focus_leave`, or a standalone `focus_action` request.",
388
+ ].join("\n");
389
+ }
390
+
391
+ const plugin = {
392
+ id: "focus-forwarder",
393
+ name: "Focus Forwarder",
394
+ configSchema: { parse },
395
+
396
+ register(api: OpenClawPluginApi) {
397
+ pluginApi = api;
398
+
430
399
  api.registerService({
431
400
  id: "focus-forwarder",
432
401
  start: (ctx) => {
433
- const cfg = parse(ctx.config.plugins?.entries?.["focus-forwarder"]?.config) as FocusForwarderConfig;
434
- syncCooldownMs = cfg.cooldownMs;
402
+ const cfg = parse(
403
+ ctx.config.plugins?.entries?.["focus-forwarder"]?.config,
404
+ ) as FocusForwarderConfig;
435
405
  service = new FocusForwarderService(cfg, api.logger);
436
406
  return service.start();
437
407
  },
438
408
  stop: () => service?.stop(),
439
409
  });
440
-
441
- api.registerTool({
442
- name: "focus_join",
443
- description: "Join Focus world with userId",
410
+
411
+ api.registerTool({
412
+ name: "focus_join",
413
+ description: "Join Focus world with userId",
444
414
  parameters: {
445
415
  type: "object",
446
- properties: { userId: { type: "string", description: "User ID to join Focus world" } },
447
- required: ["userId"],
416
+ properties: {
417
+ userId: { type: "string", description: "User ID to join Focus world" },
418
+ },
448
419
  },
449
420
  execute: async (_toolCallId, params) => {
450
- let userId = (params as any)?.userId;
421
+ let userId = (params as { userId?: string } | null)?.userId;
422
+ if (!userId) {
423
+ try {
424
+ userId = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8")).userId;
425
+ } catch {}
426
+ }
451
427
  if (!userId) {
452
- try { userId = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8")).userId; } catch {}
428
+ return { success: false, error: "No userId" };
453
429
  }
454
- if (!userId) return { success: false, error: "No userId" };
455
430
  const result = await service?.join(userId);
456
431
  return result ? { success: true, authKey: result } : { success: false, error: "Failed" };
457
432
  },
458
- });
459
-
460
- api.registerTool({
461
- name: "focus_leave",
462
- description: "Leave Focus world",
463
- parameters: { type: "object", properties: {} },
464
- execute: async (_toolCallId) => {
465
- const result = await service?.leave();
466
- return result ? { success: true } : { success: false, error: "Failed or not connected" };
467
- },
468
- });
469
-
470
- api.registerTool({
471
- name: "focus_action",
472
- description: "Send an action/pose to Focus world (e.g., dance, wave, sit, stand)",
473
- parameters: {
474
- type: "object",
475
- properties: {
476
- poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
477
- action: { type: "string", description: "Action name (e.g., High Five, Typing with Keyboard)" },
478
- bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
479
- },
480
- required: ["poseType", "action"],
481
- },
482
- execute: async (_toolCallId, params) => {
483
- const { poseType, action, bubble } = (params || {}) as { poseType?: string; action?: string; bubble?: string };
484
- if (!poseType || !action) {
485
- return { success: false, error: "poseType and action parameters are required" };
486
- }
487
- if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
488
- return { success: false, error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor` };
489
- }
490
- if (!service?.hasValidIdentity() || !service?.isConnected()) {
491
- return { success: false, error: "Not connected to Focus world" };
492
- }
493
- const config = loadSkillsConfig();
494
- if (!config?.actions?.stand || !config?.actions?.sit || !config?.actions?.lay || !config?.actions?.floor) {
495
- return { success: false, error: "Invalid skills config" };
433
+ });
434
+
435
+ api.registerTool({
436
+ name: "focus_leave",
437
+ description: "Leave Focus world",
438
+ parameters: { type: "object", properties: {} },
439
+ execute: async () => {
440
+ const result = await service?.leave();
441
+ return result ? { success: true } : { success: false, error: "Failed or not connected" };
442
+ },
443
+ });
444
+
445
+ api.registerTool({
446
+ name: "focus_action",
447
+ description:
448
+ "Send an action/pose to Focus world. Use this for explicit Focus actions and task lifecycle sync.",
449
+ parameters: {
450
+ type: "object",
451
+ properties: {
452
+ poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
453
+ action: {
454
+ type: "string",
455
+ description: "Action name (for example High Five or Typing with Keyboard)",
456
+ },
457
+ bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
458
+ },
459
+ required: ["poseType", "action"],
460
+ },
461
+ execute: async (_toolCallId, params) => {
462
+ const { poseType, action, bubble } = (params || {}) as {
463
+ poseType?: string;
464
+ action?: string;
465
+ bubble?: string;
466
+ };
467
+ if (!poseType || !action) {
468
+ return { success: false, error: "poseType and action parameters are required" };
469
+ }
470
+ if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
471
+ return {
472
+ success: false,
473
+ error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
474
+ };
475
+ }
476
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
477
+ return { success: false, error: "Not connected to Focus world" };
496
478
  }
479
+
497
480
  const normalizedPoseType = poseType as PoseType;
498
- // Validate action exists in the specified poseType
499
- const poseActions = config.actions[normalizedPoseType];
500
- const matched = poseActions.find((a: string) => a.toLowerCase() === action.toLowerCase());
481
+ const poseActions = loadSkillsConfig().actions[normalizedPoseType];
482
+ const matched = poseActions.find((entry) => entry.toLowerCase() === action.toLowerCase());
501
483
  if (!matched) {
502
- return { success: false, error: `Unknown action "${action}" for poseType "${poseType}"`, available: poseActions };
484
+ return {
485
+ success: false,
486
+ error: `Unknown action "${action}" for poseType "${poseType}"`,
487
+ available: poseActions,
488
+ };
503
489
  }
504
- // Update lastLLMResult for main agent
505
- const state = getAgentState("main");
506
- state.lastLLMResult = { poseType: normalizedPoseType, action: matched };
507
- service.sendStatus(normalizedPoseType, matched, bubble || matched, `User requested: ${action}`);
508
- return { success: true, poseType: normalizedPoseType, action: matched, bubble: bubble || matched };
490
+
491
+ const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched;
492
+ service.sendStatus(normalizedPoseType, matched, bubbleText, `Focus action: ${bubbleText}`);
493
+ return {
494
+ success: true,
495
+ poseType: normalizedPoseType,
496
+ action: matched,
497
+ bubble: bubbleText,
498
+ };
509
499
  },
510
500
  });
511
501
 
512
502
  api.registerTool({
513
503
  name: "focus_set_llm_enabled",
514
- 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.",
504
+ description:
505
+ "Enable or disable Focus Forwarder LLM status picking for message_received events.",
515
506
  parameters: {
516
507
  type: "object",
517
508
  properties: {
518
- enabled: { type: "boolean", description: "True to enable LLM-based auto action picking, false to use fallback keyword mapping only." },
509
+ enabled: {
510
+ type: "boolean",
511
+ description: "True to use LLM for message_received status sync, false to use fallback random actions.",
512
+ },
519
513
  },
520
514
  required: ["enabled"],
521
515
  },
@@ -524,15 +518,16 @@ const plugin = {
524
518
  if (typeof enabled !== "boolean") {
525
519
  return { success: false, error: "enabled must be a boolean" };
526
520
  }
521
+
527
522
  try {
528
- const nextConfig = applyLlmEnabledChange(enabled);
523
+ const nextConfig = updateSkillsConfig((current) => ({
524
+ ...current,
525
+ llm: { ...current.llm, enabled },
526
+ }));
529
527
  return {
530
528
  success: true,
531
529
  llmEnabled: nextConfig.llm.enabled,
532
530
  configPath: SKILLS_CONFIG_PATH,
533
- message: nextConfig.llm.enabled
534
- ? "Focus Forwarder LLM requests enabled"
535
- : "Focus Forwarder LLM requests disabled; fallback mapping is now active",
536
531
  };
537
532
  } catch (error) {
538
533
  return {
@@ -543,55 +538,39 @@ const plugin = {
543
538
  }
544
539
  },
545
540
  });
546
-
547
- // sendWithLLM: use LLM with cooldown
548
- const sendWithLLM = (context: string, agentId: string) => {
549
- pluginApi?.logger.debug(`[focus] hook fired: agent=${agentId} context="${context.slice(0, 50)}" hasIdentity=${service?.hasValidIdentity()}`);
550
- if (service?.hasValidIdentity()) syncStatus(context, agentId);
551
- };
552
-
553
- // sendFallback: always use fallback, no LLM (for after_tool_call)
554
- const sendFallback = (context: string, agentId: string) => {
555
- pluginApi?.logger.debug(`[focus] fallback: agent=${agentId} context="${context.slice(0, 50)}"`);
556
- if (service?.hasValidIdentity()) sendFallbackInternal(context, agentId);
557
- };
558
-
559
- api.on("message_received", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
560
- const agentId = ctx?.agentId || ctx?.sessionKey || "main";
561
- const preview = event.content?.slice(0, 30) || "new message";
562
- sendWithLLM(`[${agentId}] Received: ${preview}`, agentId);
563
- });
564
- api.on("before_agent_start", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
565
- const agentId = ctx?.agentId || ctx?.sessionKey || "main";
566
- const prompt = event?.prompt?.slice(0, 100) || "thinking";
567
- sendWithLLM(`[${agentId}] Processing: ${prompt}`, agentId);
568
- });
569
- api.on("before_tool_call", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
570
- const agentId = ctx?.agentId || ctx?.sessionKey || "main";
571
- pluginApi?.logger.debug(`[focus] before_tool_call ctx: ${JSON.stringify(ctx)}`);
572
- const params = event.params ? JSON.stringify(event.params) : "";
573
- sendWithLLM(`[${agentId}] Tool: ${event.toolName}${params ? ` ${params}` : ""}`, agentId);
574
- });
575
- api.on("after_tool_call", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
576
- const agentId = ctx?.agentId || ctx?.sessionKey || "main";
577
- sendFallback(`[${agentId}] Done: ${event.toolName}`, agentId);
578
- });
579
- api.on("agent_end", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
580
- const agentId = ctx?.agentId || ctx?.sessionKey || "main";
581
- // Use last LLM poseType but pick done action for that pose
582
- if (!service?.hasValidIdentity() || !service?.isConnected()) return;
583
- const state = getAgentState(agentId);
584
- if (!isLlmEnabled()) {
585
- const done = pickActionFallback(`[${agentId}] done`);
586
- service.sendStatus(done.poseType, done.action, done.bubble || "Done!", truncateLog(`[${agentId}] Task complete`));
587
- } else {
588
- const poseType = state.lastLLMResult?.poseType || "stand";
589
- const action = getDoneActionForPose(poseType);
590
- service.sendStatus(poseType, action, "Done!", truncateLog(`[${agentId}] Task complete`));
541
+
542
+ api.on("before_prompt_build", () => {
543
+ if (!service?.hasValidIdentity() || !service?.isConnected() || !isLlmEnabled()) {
544
+ return;
545
+ }
546
+ return {
547
+ prependContext: buildFocusPrompt(),
548
+ };
549
+ });
550
+
551
+ api.on("message_received", async (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
552
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
553
+ return;
591
554
  }
592
- cleanupStaleAgents();
555
+
556
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
557
+ const content =
558
+ typeof event?.content === "string" && event.content.trim()
559
+ ? event.content.trim()
560
+ : JSON.stringify(event ?? "new message");
561
+ const context = `[${agentId}] Received: ${content}`;
562
+ const status = isLlmEnabled()
563
+ ? await pickActionWithLlm(context)
564
+ : buildMessageFallbackStatus(context);
565
+
566
+ service.sendStatus(
567
+ status.poseType,
568
+ status.action,
569
+ status.bubble || status.action,
570
+ truncateLog(context),
571
+ );
593
572
  });
594
573
  },
595
- };
596
-
597
- export default plugin;
574
+ };
575
+
576
+ export default plugin;
@@ -5,26 +5,21 @@
5
5
  "version": "0.2.1",
6
6
  "author": "OpenClaw",
7
7
  "skills": ["./skills/focus-forwarder"],
8
- "configSchema": {
9
- "type": "object",
10
- "additionalProperties": false,
11
- "properties": {
8
+ "configSchema": {
9
+ "type": "object",
10
+ "additionalProperties": false,
11
+ "properties": {
12
12
  "wsUrl": {
13
13
  "type": "string"
14
14
  },
15
- "enabled": {
16
- "type": "boolean",
17
- "default": true
18
- },
19
- "cooldownMs": {
20
- "type": "number",
21
- "default": 15000
22
- }
23
- }
24
- },
25
- "uiHints": {
15
+ "enabled": {
16
+ "type": "boolean",
17
+ "default": true
18
+ }
19
+ }
20
+ },
21
+ "uiHints": {
26
22
  "wsUrl": { "label": "WebSocket URL", "placeholder": "ws://host:port/ws/openclaw" },
27
- "enabled": { "label": "Enable Plugin" },
28
- "cooldownMs": { "label": "Status Cooldown (ms)", "placeholder": "15000" }
29
- }
30
- }
23
+ "enabled": { "label": "Enable Plugin" }
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/focus-forwarder",
3
- "version": "0.0.1-alpha.8",
3
+ "version": "0.0.1-alpha.9",
4
4
  "description": "Forward OpenClaw agent events to external WebSocket server for visualization",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -25,7 +25,7 @@ Before installing the plugin, save `userId` to the `identity.json` file under th
25
25
  Then install and enable the plugin:
26
26
 
27
27
  ```bash
28
- openclaw plugins install @yahaha-studio/focus-forwarder@0.0.1-alpha.8
28
+ openclaw plugins install @yahaha-studio/focus-forwarder@0.0.1-alpha.9
29
29
  openclaw plugins enable focus-forwarder
30
30
  ```
31
31
 
@@ -68,7 +68,7 @@ focus_leave()
68
68
 
69
69
  ### focus_action
70
70
 
71
- 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".
71
+ 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". The plugin also injects prompt guidance so OpenClaw uses this tool to sync task lifecycle status to Focus App.
72
72
 
73
73
  ```text
74
74
  focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")
@@ -79,17 +79,6 @@ Parameters:
79
79
  - `action` (required): Action name to perform
80
80
  - `bubble` (optional): Bubble text to display, max 5 words
81
81
 
82
- ### focus_set_llm_enabled
83
-
84
- 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.
85
-
86
- ```text
87
- focus_set_llm_enabled(enabled: false)
88
- ```
89
-
90
- Parameters:
91
- - `enabled` (required): `true` to use `pickActionWithLLM` for automatic status sync, `false` to force all automatic status updates to use fallback keyword mapping
92
-
93
82
  ## Available Actions
94
83
 
95
84
  Use action names exactly as listed below.
@@ -164,12 +153,6 @@ User says: "Sit down and type"
164
153
  User says: "Lie flat"
165
154
  -> `focus_action(poseType: "lay", action: "Lie Flat", bubble: "Relaxing...")`
166
155
 
167
- User says: "Disable Focus Forwarder LLM requests"
168
- -> `focus_set_llm_enabled(enabled: false)`
169
-
170
- User says: "Enable Focus Forwarder LLM requests again"
171
- -> `focus_set_llm_enabled(enabled: true)`
172
-
173
156
  ## Files
174
157
 
175
158
  The plugin stores files under the current user's home directory in `.openclaw/focus-world/`.
@@ -179,37 +162,26 @@ The plugin stores files under the current user's home directory in `.openclaw/fo
179
162
  - Windows: `%USERPROFILE%\\.openclaw\\focus-world\\`
180
163
 
181
164
  - `identity.json` - userId (bootstrap) and authKey (managed by plugin)
182
- - `skills-config.json` - actions, fallbacks, and `llm.enabled` runtime config
165
+ - `skills-config.json` - allowed action lists used by `focus_action`
183
166
 
184
167
  ## Skills Config
185
168
 
186
- Custom actions and the Focus Forwarder LLM toggle can be configured in the home-directory `skills-config.json` file:
169
+ Custom actions can be configured in the home-directory `skills-config.json` file:
187
170
 
188
171
  ```json
189
172
  {
190
- "llm": {
191
- "enabled": true
192
- },
193
173
  "actions": {
194
174
  "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"],
195
175
  "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"],
196
176
  "lay": ["Bend One Knee", "Sleep Curl Up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side"],
197
177
  "floor": ["Seiza", "Cross Legged", "Knee Hug"]
198
- },
199
- "fallbacks": {
200
- "done": { "poseType": "stand", "action": "Yay", "bubble": "Done!" },
201
- "thinking": { "poseType": "stand", "action": "Wait", "bubble": "Thinking..." },
202
- "working": { "poseType": "stand", "action": "Arms Crossed", "bubble": "Working" }
203
178
  }
204
179
  }
205
180
  ```
206
181
 
207
- 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.
208
-
209
182
  ## How It Works
210
183
 
211
- - Plugin automatically syncs status when you are working
212
- - Automatic sync uses LLM only when `llm.enabled` is `true`
213
- - `focus_set_llm_enabled` updates the home-directory `skills-config.json` file and takes effect immediately
184
+ - When Focus World is connected, the plugin injects prompt instructions before agent runs
185
+ - The injected prompt tells OpenClaw to call `focus_action` before work starts, during active execution, and again when the task is complete
214
186
  - Use `focus_action` to manually perform specific actions on user request
215
187
  - Bubble text shows short status, up to 5 words
package/src/config.ts CHANGED
@@ -1,10 +1,9 @@
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://43.106.148.251:48870/ws/openclaw",
7
- enabled: config.enabled ?? true,
8
- cooldownMs: config.cooldownMs ?? 15000,
9
- };
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
+ };
9
+ }
package/src/service.ts CHANGED
@@ -3,7 +3,7 @@ import * as fs from "fs";
3
3
  import os from "node:os";
4
4
  import * as path from "path";
5
5
  import type { Logger } from "openclaw/plugin-sdk";
6
- import type { FocusForwarderConfig, FocusIdentity, StatusPayload } from "./types.js";
6
+ import type { FocusForwarderConfig, FocusIdentity, PoseType, StatusPayload } from "./types.js";
7
7
 
8
8
  const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
9
9
  const IDENTITY_PATH = path.join(IDENTITY_DIR, "identity.json");
@@ -113,7 +113,7 @@ export class FocusForwarderService {
113
113
  this.logger.info("AuthKey cleared");
114
114
  }
115
115
 
116
- sendStatus(poseType: string, action: string, bubble: string, log: string): void {
116
+ sendStatus(poseType: PoseType, action: string, bubble: string, log: string): void {
117
117
  if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
118
118
  const payload: StatusPayload = {
119
119
  type: "status",
package/src/types.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export type FocusForwarderConfig = {
2
2
  wsUrl: string;
3
3
  enabled: boolean;
4
- cooldownMs: number;
5
4
  };
6
5
 
7
6
  export type PoseType = "stand" | "sit" | "lay" | "floor";
@@ -14,11 +13,6 @@ export type ActionResult = {
14
13
 
15
14
  export type SkillsConfig = {
16
15
  actions: Record<PoseType, string[]>;
17
- fallbacks: {
18
- done: ActionResult;
19
- thinking: ActionResult;
20
- working: ActionResult;
21
- };
22
16
  llm: {
23
17
  enabled: boolean;
24
18
  };