@yahaha-studio/focus-forwarder 0.0.1-alpha.1 → 0.0.1-alpha.11

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,429 +1,810 @@
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;
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
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 {
9
+ ActionResult,
10
+ ClockAction,
11
+ ClockConfig,
12
+ FocusForwarderConfig,
13
+ PomodoroPhase,
14
+ PoseType,
15
+ SkillsConfig,
16
+ } from "./src/types.js";
17
+
18
+ const DEFAULT_ACTIONS: SkillsConfig["actions"] = {
19
+ stand: ["High Five", "Listen Music", "Arms Crossed", "Epiphany", "Yay", "Tired", "Wait"],
20
+ sit: ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Hand Cramp", "Laze"],
21
+ lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
22
+ floor: ["Seiza", "Cross Legged", "Knee Hug"],
23
+ };
24
+
25
+ const DEFAULT_SKILLS_CONFIG: SkillsConfig = {
26
+ actions: DEFAULT_ACTIONS,
27
+ llm: {
28
+ enabled: true,
29
+ },
30
+ };
31
+
32
+ const FOCUS_WORLD_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
33
+ const SKILLS_CONFIG_PATH = path.join(FOCUS_WORLD_DIR, "skills-config.json");
34
+ const IDENTITY_PATH = path.join(FOCUS_WORLD_DIR, "identity.json");
35
+ const LLM_SESSION_PATH = path.join(FOCUS_WORLD_DIR, "llm-session.json");
36
+
37
+ let cachedConfig: SkillsConfig | null = null;
38
+ let cachedConfigMtime = 0;
39
+ let service: FocusForwarderService | null = null;
40
+ let pluginApi: OpenClawPluginApi | null = null;
41
+ let coreApiPromise: Promise<{ runEmbeddedPiAgent?: (params: Record<string, unknown>) => Promise<any> }> | null =
42
+ null;
43
+
44
+ function sanitizeActions(value: unknown, fallback: string[]): string[] {
45
+ if (!Array.isArray(value)) {
46
+ return fallback;
47
+ }
48
+ const actions = value.filter(
49
+ (item): item is string => typeof item === "string" && item.trim().length > 0,
50
+ );
51
+ return actions.length > 0 ? actions : fallback;
52
+ }
53
+
54
+ function normalizeSkillsConfig(value: unknown): SkillsConfig {
55
+ const raw = value && typeof value === "object" ? (value as Partial<SkillsConfig>) : {};
56
+ const actions = raw.actions;
57
+ return {
58
+ actions: {
59
+ stand: sanitizeActions(actions?.stand, DEFAULT_ACTIONS.stand),
60
+ sit: sanitizeActions(actions?.sit, DEFAULT_ACTIONS.sit),
61
+ lay: sanitizeActions(actions?.lay, DEFAULT_ACTIONS.lay),
62
+ floor: sanitizeActions(actions?.floor, DEFAULT_ACTIONS.floor),
63
+ },
64
+ llm: {
65
+ enabled: typeof raw.llm?.enabled === "boolean" ? raw.llm.enabled : DEFAULT_SKILLS_CONFIG.llm.enabled,
66
+ },
67
+ };
68
+ }
69
+
70
+ function updateCachedSkillsConfig(config: SkillsConfig): SkillsConfig {
71
+ cachedConfig = config;
72
+ try {
73
+ cachedConfigMtime = fs.existsSync(SKILLS_CONFIG_PATH)
74
+ ? fs.statSync(SKILLS_CONFIG_PATH).mtimeMs
75
+ : 0;
76
+ } catch {
77
+ cachedConfigMtime = 0;
78
+ }
79
+ return config;
80
+ }
81
+
82
+ function loadSkillsConfig(): SkillsConfig {
83
+ try {
84
+ if (fs.existsSync(SKILLS_CONFIG_PATH)) {
85
+ const stat = fs.statSync(SKILLS_CONFIG_PATH);
86
+ if (stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
87
+ const raw = fs.readFileSync(SKILLS_CONFIG_PATH, "utf-8");
88
+ updateCachedSkillsConfig(normalizeSkillsConfig(JSON.parse(raw)));
89
+ pluginApi?.logger.debug("[focus] loaded skills config");
90
+ }
91
+ return cachedConfig!;
92
+ }
93
+ } catch (error) {
94
+ pluginApi?.logger.warn(`[focus] failed to load skills config: ${error}`);
95
+ }
96
+ return updateCachedSkillsConfig(DEFAULT_SKILLS_CONFIG);
97
+ }
98
+
99
+ function saveSkillsConfig(config: SkillsConfig): SkillsConfig {
100
+ const normalized = normalizeSkillsConfig(config);
101
+ fs.mkdirSync(FOCUS_WORLD_DIR, { recursive: true });
102
+ fs.writeFileSync(SKILLS_CONFIG_PATH, JSON.stringify(normalized, null, 2), "utf-8");
103
+ return updateCachedSkillsConfig(normalized);
104
+ }
105
+
106
+ function updateSkillsConfig(mutator: (config: SkillsConfig) => SkillsConfig): SkillsConfig {
107
+ return saveSkillsConfig(mutator(loadSkillsConfig()));
108
+ }
109
+
110
+ function isLlmEnabled(): boolean {
111
+ return loadSkillsConfig().llm.enabled;
112
+ }
113
+
114
+ function findPackageRoot(startDir: string, name: string): string | null {
115
+ let dir = startDir;
116
+ for (;;) {
117
+ const packageJsonPath = path.join(dir, "package.json");
118
+ try {
119
+ if (fs.existsSync(packageJsonPath)) {
120
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { name?: string };
121
+ if (pkg.name === name) {
122
+ return dir;
123
+ }
124
+ }
125
+ } catch {}
126
+ const parent = path.dirname(dir);
127
+ if (parent === dir) {
128
+ return null;
129
+ }
130
+ dir = parent;
131
+ }
132
+ }
133
+
134
+ function resolveOpenClawRoot(): string {
135
+ const override = process.env.OPENCLAW_ROOT?.trim();
136
+ if (override) {
137
+ return override;
138
+ }
139
+
140
+ const candidates = new Set<string>();
141
+ if (process.argv[1]) {
142
+ candidates.add(path.dirname(process.argv[1]));
143
+ }
144
+ candidates.add(process.cwd());
145
+ try {
146
+ candidates.add(path.dirname(fileURLToPath(import.meta.url)));
147
+ } catch {}
148
+
149
+ for (const start of candidates) {
150
+ const found = findPackageRoot(start, "openclaw");
151
+ if (found) {
152
+ return found;
153
+ }
154
+ }
155
+
156
+ throw new Error("Unable to resolve OpenClaw root");
157
+ }
158
+
159
+ async function loadCoreApi(): Promise<{
160
+ runEmbeddedPiAgent?: (params: Record<string, unknown>) => Promise<any>;
161
+ }> {
162
+ if (!coreApiPromise) {
163
+ coreApiPromise = (async () => {
164
+ const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
165
+ if (!fs.existsSync(distPath)) {
166
+ throw new Error(`Missing extensionAPI.js at ${distPath}`);
167
+ }
168
+ return (await import(pathToFileURL(distPath).href)) as {
169
+ runEmbeddedPiAgent?: (params: Record<string, unknown>) => Promise<any>;
170
+ };
171
+ })();
172
+ }
173
+ return coreApiPromise;
174
+ }
175
+
176
+ function truncateLog(text: string, maxLen = 150): string {
177
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
178
+ }
179
+
180
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
181
+ return !!value && typeof value === "object" && !Array.isArray(value);
182
+ }
183
+
184
+ function isNonNegativeInteger(value: unknown): value is number {
185
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
186
+ }
187
+
188
+ function isPositiveInteger(value: unknown): value is number {
189
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
190
+ }
191
+
192
+ function isClockAction(value: unknown): value is ClockAction {
193
+ return ["set", "stop", "pause", "resume", "nextSession"].includes(String(value));
194
+ }
195
+
196
+ function isPomodoroPhase(value: unknown): value is PomodoroPhase {
197
+ return ["focusing", "shortBreak", "longBreak"].includes(String(value));
198
+ }
199
+
200
+ function getPomodoroPhaseDuration(
201
+ phase: PomodoroPhase,
202
+ focusSeconds: number,
203
+ shortBreakSeconds: number,
204
+ longBreakSeconds: number,
205
+ ): number {
206
+ if (phase === "shortBreak") {
207
+ return shortBreakSeconds;
208
+ }
209
+ if (phase === "longBreak") {
210
+ return longBreakSeconds;
211
+ }
212
+ return focusSeconds;
213
+ }
214
+
215
+ function normalizeClockConfig(value: unknown): { clock?: ClockConfig; error?: string } {
216
+ if (!isPlainObject(value)) {
217
+ return { error: "clock must be an object" };
218
+ }
219
+
220
+ const mode = value.mode;
221
+ if (!["pomodoro", "countDown", "countUp"].includes(String(mode))) {
222
+ return { error: "clock.mode must be pomodoro, countDown, or countUp" };
223
+ }
224
+
225
+ const running = typeof value.running === "boolean" ? value.running : true;
226
+
227
+ if (mode === "pomodoro") {
228
+ const focusSeconds = value.focusSeconds;
229
+ const shortBreakSeconds = value.shortBreakSeconds;
230
+ const longBreakSeconds = value.longBreakSeconds;
231
+ const sessionCount = value.sessionCount;
232
+ const currentSession = value.currentSession ?? 1;
233
+ const phase = value.phase ?? "focusing";
234
+
235
+ if (!isPositiveInteger(focusSeconds)) {
236
+ return { error: "clock.focusSeconds must be a positive integer" };
237
+ }
238
+ if (!isPositiveInteger(shortBreakSeconds)) {
239
+ return { error: "clock.shortBreakSeconds must be a positive integer" };
240
+ }
241
+ if (!isPositiveInteger(longBreakSeconds)) {
242
+ return { error: "clock.longBreakSeconds must be a positive integer" };
243
+ }
244
+ if (!isPositiveInteger(sessionCount)) {
245
+ return { error: "clock.sessionCount must be a positive integer" };
246
+ }
247
+ if (!isPositiveInteger(currentSession)) {
248
+ return { error: "clock.currentSession must be a positive integer" };
249
+ }
250
+ if (currentSession > sessionCount) {
251
+ return { error: "clock.currentSession cannot be greater than clock.sessionCount" };
252
+ }
253
+ if (!isPomodoroPhase(phase)) {
254
+ return { error: "clock.phase must be focusing, shortBreak, or longBreak" };
255
+ }
256
+
257
+ const defaultRemainingSeconds = getPomodoroPhaseDuration(
258
+ phase,
259
+ focusSeconds,
260
+ shortBreakSeconds,
261
+ longBreakSeconds,
262
+ );
263
+ const remainingSeconds = value.remainingSeconds ?? defaultRemainingSeconds;
264
+ if (!isNonNegativeInteger(remainingSeconds)) {
265
+ return { error: "clock.remainingSeconds must be a non-negative integer" };
266
+ }
267
+
268
+ return {
269
+ clock: {
270
+ mode: "pomodoro",
271
+ running,
272
+ focusSeconds,
273
+ shortBreakSeconds,
274
+ longBreakSeconds,
275
+ sessionCount,
276
+ currentSession,
277
+ phase,
278
+ remainingSeconds,
279
+ },
280
+ };
281
+ }
282
+
283
+ if (mode === "countDown") {
284
+ const durationSeconds = value.durationSeconds;
285
+ if (!isPositiveInteger(durationSeconds)) {
286
+ return { error: "clock.durationSeconds must be a positive integer" };
287
+ }
288
+ const remainingSeconds = value.remainingSeconds ?? durationSeconds;
289
+ if (!isNonNegativeInteger(remainingSeconds)) {
290
+ return { error: "clock.remainingSeconds must be a non-negative integer" };
291
+ }
292
+ return {
293
+ clock: {
294
+ mode: "countDown",
295
+ running,
296
+ durationSeconds,
297
+ remainingSeconds,
298
+ },
299
+ };
300
+ }
301
+
302
+ const elapsedSeconds = value.elapsedSeconds ?? 0;
303
+ if (!isNonNegativeInteger(elapsedSeconds)) {
304
+ return { error: "clock.elapsedSeconds must be a non-negative integer" };
305
+ }
306
+ return {
307
+ clock: {
308
+ mode: "countUp",
309
+ running,
310
+ elapsedSeconds,
311
+ },
312
+ };
313
+ }
314
+
315
+ function pickRandomAction(actions: string[]): string {
316
+ return actions[Math.floor(Math.random() * actions.length)];
317
+ }
318
+
319
+ function buildFallbackCandidates(context: string): Record<PoseType, string[]> {
320
+ const lowerContext = context.toLowerCase();
321
+ if (
322
+ lowerContext.includes("sleep") ||
323
+ lowerContext.includes("rest") ||
324
+ lowerContext.includes("lie") ||
325
+ lowerContext.includes("nap")
326
+ ) {
327
+ return {
328
+ stand: [],
329
+ sit: [],
330
+ lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
331
+ floor: [],
332
+ };
333
+ }
334
+
335
+ if (
336
+ lowerContext.includes("sit") ||
337
+ lowerContext.includes("write") ||
338
+ lowerContext.includes("typing") ||
339
+ lowerContext.includes("study") ||
340
+ lowerContext.includes("think") ||
341
+ lowerContext.includes("work")
342
+ ) {
343
+ return {
344
+ stand: [],
345
+ sit: ["Typing with Keyboard", "Writing", "Thinking", "Study Look At", "Hand Cramp"],
346
+ lay: [],
347
+ floor: [],
348
+ };
349
+ }
350
+
351
+ return {
352
+ stand: ["Wait", "Arms Crossed", "Epiphany", "Tired"],
353
+ sit: ["Typing with Keyboard", "Thinking"],
354
+ lay: [],
355
+ floor: [],
356
+ };
357
+ }
358
+
359
+ function buildMessageFallbackStatus(context: string): ActionResult {
360
+ const config = loadSkillsConfig();
361
+ const candidates = buildFallbackCandidates(context);
362
+ const poseOrder: PoseType[] = ["sit", "stand", "lay", "floor"];
363
+ const poseType =
364
+ poseOrder.find((pose) => {
365
+ const preferred = candidates[pose];
366
+ if (preferred.length === 0) {
367
+ return false;
368
+ }
369
+ return config.actions[pose].some((action) =>
370
+ preferred.some((candidate) => candidate.toLowerCase() === action.toLowerCase()),
371
+ );
372
+ }) ?? "stand";
373
+
374
+ const preferredPool = candidates[poseType];
375
+ const availableActions = config.actions[poseType];
376
+ const actionPool =
377
+ preferredPool.length > 0
378
+ ? availableActions.filter((action) =>
379
+ preferredPool.some((candidate) => candidate.toLowerCase() === action.toLowerCase()),
380
+ )
381
+ : availableActions;
382
+ const action = pickRandomAction(actionPool.length > 0 ? actionPool : availableActions);
383
+
384
+ return {
385
+ poseType,
386
+ action,
387
+ bubble: poseType === "sit" ? "Working" : poseType === "lay" ? "Resting" : "Thinking",
388
+ };
389
+ }
390
+
391
+ async function pickActionWithLlm(context: string): Promise<ActionResult> {
392
+ const fallback = buildMessageFallbackStatus(context);
393
+
394
+ try {
395
+ const coreApi = await loadCoreApi();
396
+ const runEmbeddedPiAgent = coreApi.runEmbeddedPiAgent;
397
+ if (typeof runEmbeddedPiAgent !== "function") {
398
+ throw new Error("runEmbeddedPiAgent is unavailable");
399
+ }
400
+
401
+ const primary = pluginApi?.config?.agents?.defaults?.model?.primary;
402
+ const provider = typeof primary === "string" ? primary.split("/")[0] : undefined;
403
+ const model = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
404
+ const authProfiles = pluginApi?.config?.auth?.profiles ?? {};
405
+ const authProfileId =
406
+ provider && typeof authProfiles === "object"
407
+ ? Object.keys(authProfiles).find((key) => key.startsWith(`${provider}:`))
408
+ : undefined;
409
+
410
+ const actions = loadSkillsConfig().actions;
411
+ const prompt = `Pick avatar pose for: "${context}"
412
+ Available poseTypes and actions:
413
+ - stand: ${actions.stand.join(", ")}
414
+ - sit: ${actions.sit.join(", ")}
415
+ - lay: ${actions.lay.join(", ")}
416
+ - floor: ${actions.floor.join(", ")}
417
+ Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bubble":"<5 words>"}`;
418
+
419
+ const result = await runEmbeddedPiAgent({
420
+ sessionId: `focus-action-${Date.now()}`,
421
+ sessionFile: LLM_SESSION_PATH,
422
+ workspaceDir: pluginApi?.config?.agents?.defaults?.workspace ?? process.cwd(),
423
+ config: pluginApi?.config,
424
+ prompt,
425
+ provider,
426
+ model,
427
+ authProfileId,
428
+ timeoutMs: 10000,
429
+ runId: `focus-${Date.now()}`,
430
+ lane: "focus-llm",
431
+ });
432
+
433
+ const text = (result?.payloads ?? [])
434
+ .filter((payload: any) => !payload?.isError && typeof payload?.text === "string")
435
+ .map((payload: any) => payload.text)
436
+ .join("\n")
437
+ .trim();
438
+
439
+ if (!text) {
440
+ return fallback;
441
+ }
442
+
443
+ const cleaned = text.replace(/```json?\n?|\n?```/g, "").trim();
444
+ const parsed = JSON.parse(cleaned) as Partial<ActionResult>;
445
+ if (!parsed.poseType || !parsed.action) {
446
+ pluginApi?.logger.warn(`[focus] invalid LLM status payload: ${cleaned}`);
447
+ return fallback;
448
+ }
449
+ if (!["stand", "sit", "lay", "floor"].includes(parsed.poseType)) {
450
+ pluginApi?.logger.warn(`[focus] invalid poseType from LLM: ${parsed.poseType}`);
451
+ return fallback;
452
+ }
453
+
454
+ const poseType = parsed.poseType as PoseType;
455
+ const matchedAction = loadSkillsConfig().actions[poseType].find(
456
+ (entry) => entry.toLowerCase() === parsed.action?.toLowerCase(),
457
+ );
458
+ if (!matchedAction) {
459
+ pluginApi?.logger.warn(`[focus] unknown action from LLM: ${parsed.action}`);
460
+ return fallback;
461
+ }
462
+
463
+ return {
464
+ poseType,
465
+ action: matchedAction,
466
+ bubble:
467
+ typeof parsed.bubble === "string" && parsed.bubble.trim() ? parsed.bubble.trim() : matchedAction,
468
+ };
469
+ } catch (error) {
470
+ pluginApi?.logger.warn(`[focus] failed to pick action with LLM: ${error}`);
471
+ return fallback;
472
+ }
473
+ }
474
+
475
+ function buildFocusPrompt(): string {
476
+ return [
477
+ "Focus App status sync is available for substantive work in this turn.",
478
+ "Use `focus_action` when it is useful to reflect your current task state in Focus App.",
479
+ "Choose the poseType, action, and bubble from the actual task context. Do not rely on fixed default actions from this prompt.",
480
+ "Rules:",
481
+ "- Use `focus_action` sparingly. Only send meaningful status changes, not every tool call.",
482
+ "- If the user explicitly asks for a specific Focus pose or action, follow the user's request.",
483
+ "- If the user explicitly says not to sync Focus, skip Focus tool calls.",
484
+ "- Use `focus_clock` only when you judge it helpful to mark or communicate the duration of the current task.",
485
+ "- `focus_clock` is optional. Prefer it for timing-oriented work such as countdowns, count-up tracking, or pomodoro sessions.",
486
+ "- Do not add automatic Focus sync when the task itself is only about `focus_join`, `focus_leave`, `focus_action`, or `focus_clock`.",
487
+ ].join("\n");
488
+ }
489
+
490
+ const plugin = {
491
+ id: "focus-forwarder",
492
+ name: "Focus Forwarder",
493
+ configSchema: { parse },
494
+
495
+ register(api: OpenClawPluginApi) {
496
+ pluginApi = api;
497
+
498
+ api.registerService({
499
+ id: "focus-forwarder",
500
+ start: (ctx) => {
501
+ const cfg = parse(
502
+ ctx.config.plugins?.entries?.["focus-forwarder"]?.config,
503
+ ) as FocusForwarderConfig;
504
+ service = new FocusForwarderService(cfg, api.logger);
505
+ return service.start();
506
+ },
507
+ stop: () => service?.stop(),
508
+ });
509
+
510
+ api.registerTool({
511
+ name: "focus_join",
512
+ description: "Join Focus world with mateId, the current OpenClaw name, and a short self-description",
513
+ parameters: {
514
+ type: "object",
515
+ properties: {
516
+ mateId: { type: "string", description: "Mate ID to join Focus world" },
517
+ openclawName: {
518
+ type: "string",
519
+ description: "Current OpenClaw name to include in the join message",
520
+ },
521
+ openclawDescription: {
522
+ type: "string",
523
+ description: "Short self-description covering OpenClaw personality and role",
524
+ },
525
+ },
526
+ required: ["openclawName", "openclawDescription"],
527
+ },
528
+ execute: async (_toolCallId, params) => {
529
+ let mateId = (params as { mateId?: string } | null)?.mateId;
530
+ const openclawName = (params as { openclawName?: string } | null)?.openclawName?.trim();
531
+ const openclawDescription = (
532
+ params as { openclawDescription?: string } | null
533
+ )?.openclawDescription?.trim();
534
+ if (!mateId) {
535
+ try {
536
+ const identity = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8")) as {
537
+ mateId?: string;
538
+ userId?: string;
539
+ };
540
+ mateId = identity.mateId ?? identity.userId;
541
+ } catch {}
542
+ }
543
+ if (!mateId) {
544
+ return { success: false, error: "No mateId" };
545
+ }
546
+ if (!openclawName) {
547
+ return { success: false, error: "No openclawName" };
548
+ }
549
+ if (!openclawDescription) {
550
+ return { success: false, error: "No openclawDescription" };
551
+ }
552
+ const result = await service?.join(mateId, openclawName, openclawDescription);
553
+ return result ? { success: true, authKey: result } : { success: false, error: "Failed" };
554
+ },
555
+ });
556
+
557
+ api.registerTool({
558
+ name: "focus_leave",
559
+ description: "Leave Focus world",
560
+ parameters: { type: "object", properties: {} },
561
+ execute: async () => {
562
+ const result = await service?.leave();
563
+ return result ? { success: true } : { success: false, error: "Failed or not connected" };
564
+ },
565
+ });
566
+
567
+ api.registerTool({
568
+ name: "focus_action",
569
+ description:
570
+ "Send an action/pose to Focus world. Use this for explicit Focus actions and task lifecycle sync.",
571
+ parameters: {
572
+ type: "object",
573
+ properties: {
574
+ poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
575
+ action: {
576
+ type: "string",
577
+ description: "Action name (for example High Five or Typing with Keyboard)",
578
+ },
579
+ bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
580
+ },
581
+ required: ["poseType", "action"],
582
+ },
583
+ execute: async (_toolCallId, params) => {
584
+ const { poseType, action, bubble } = (params || {}) as {
585
+ poseType?: string;
586
+ action?: string;
587
+ bubble?: string;
588
+ };
589
+ if (!poseType || !action) {
590
+ return { success: false, error: "poseType and action parameters are required" };
591
+ }
592
+ if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
593
+ return {
594
+ success: false,
595
+ error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor`,
596
+ };
597
+ }
598
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
599
+ return { success: false, error: "Not connected to Focus world" };
600
+ }
601
+
602
+ const normalizedPoseType = poseType as PoseType;
603
+ const poseActions = loadSkillsConfig().actions[normalizedPoseType];
604
+ const matched = poseActions.find((entry) => entry.toLowerCase() === action.toLowerCase());
605
+ if (!matched) {
606
+ return {
607
+ success: false,
608
+ error: `Unknown action "${action}" for poseType "${poseType}"`,
609
+ available: poseActions,
610
+ };
611
+ }
612
+
613
+ const bubbleText = typeof bubble === "string" && bubble.trim() ? bubble.trim() : matched;
614
+ service.sendStatus(normalizedPoseType, matched, bubbleText, `Focus action: ${bubbleText}`);
615
+ return {
616
+ success: true,
617
+ poseType: normalizedPoseType,
618
+ action: matched,
619
+ bubble: bubbleText,
620
+ };
621
+ },
622
+ });
623
+
624
+ api.registerTool({
625
+ name: "focus_clock",
626
+ description:
627
+ "Send clock commands to Focus world, including pomodoro, countdown, count-up, stop, pause, resume, and nextSession.",
628
+ parameters: {
629
+ type: "object",
630
+ properties: {
631
+ action: {
632
+ type: "string",
633
+ description: "Clock action: set, stop, pause, resume, or nextSession",
634
+ },
635
+ requestId: {
636
+ type: "string",
637
+ description: "Optional request ID for server-side tracing or deduplication",
638
+ },
639
+ clock: {
640
+ type: "object",
641
+ description: "Required when action=set. Defines the pomodoro, countDown, or countUp clock payload.",
642
+ properties: {
643
+ mode: {
644
+ type: "string",
645
+ description: "Clock mode: pomodoro, countDown, or countUp",
646
+ },
647
+ running: {
648
+ type: "boolean",
649
+ description: "Optional running state. Defaults to true.",
650
+ },
651
+ focusSeconds: {
652
+ type: "number",
653
+ description: "Pomodoro focus duration in seconds",
654
+ },
655
+ shortBreakSeconds: {
656
+ type: "number",
657
+ description: "Pomodoro short break duration in seconds",
658
+ },
659
+ longBreakSeconds: {
660
+ type: "number",
661
+ description: "Pomodoro long break duration in seconds",
662
+ },
663
+ sessionCount: {
664
+ type: "number",
665
+ description: "Pomodoro total focus sessions before long break",
666
+ },
667
+ currentSession: {
668
+ type: "number",
669
+ description: "Pomodoro current session number. Defaults to 1.",
670
+ },
671
+ phase: {
672
+ type: "string",
673
+ description: "Pomodoro phase: focusing, shortBreak, or longBreak",
674
+ },
675
+ durationSeconds: {
676
+ type: "number",
677
+ description: "Countdown duration in seconds",
678
+ },
679
+ remainingSeconds: {
680
+ type: "number",
681
+ description: "Optional remaining seconds for pomodoro/countDown",
682
+ },
683
+ elapsedSeconds: {
684
+ type: "number",
685
+ description: "Optional elapsed seconds for countUp. Defaults to 0.",
686
+ },
687
+ },
688
+ },
689
+ },
690
+ required: ["action"],
691
+ },
692
+ execute: async (_toolCallId, params) => {
693
+ const { action, requestId, clock } = (params || {}) as {
694
+ action?: unknown;
695
+ requestId?: unknown;
696
+ clock?: unknown;
697
+ };
698
+
699
+ if (!isClockAction(action)) {
700
+ return {
701
+ success: false,
702
+ error: "action must be one of: set, stop, pause, resume, nextSession",
703
+ };
704
+ }
705
+ if (requestId !== undefined && typeof requestId !== "string") {
706
+ return { success: false, error: "requestId must be a string when provided" };
707
+ }
708
+ const normalizedRequestId = typeof requestId === "string" ? requestId : undefined;
709
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
710
+ return { success: false, error: "Not connected to Focus world" };
711
+ }
712
+
713
+ let normalizedClock: ClockConfig | undefined;
714
+ if (action === "set") {
715
+ const { clock: nextClock, error } = normalizeClockConfig(clock);
716
+ if (!nextClock) {
717
+ return { success: false, error: error ?? "Invalid clock payload" };
718
+ }
719
+ normalizedClock = nextClock;
720
+ }
721
+
722
+ const sent = service.sendClock(action, normalizedClock, normalizedRequestId);
723
+ if (!sent) {
724
+ return { success: false, error: "Failed to send clock payload" };
725
+ }
726
+
727
+ return {
728
+ success: true,
729
+ action,
730
+ requestId: normalizedRequestId,
731
+ ...(normalizedClock ? { clock: normalizedClock } : {}),
732
+ };
733
+ },
734
+ });
735
+
736
+ api.registerTool({
737
+ name: "focus_set_llm_enabled",
738
+ description:
739
+ "Enable or disable Focus Forwarder LLM status picking for message_received events.",
740
+ parameters: {
741
+ type: "object",
742
+ properties: {
743
+ enabled: {
744
+ type: "boolean",
745
+ description: "True to use LLM for message_received status sync, false to use fallback random actions.",
746
+ },
747
+ },
748
+ required: ["enabled"],
749
+ },
750
+ execute: async (_toolCallId, params) => {
751
+ const enabled = (params as { enabled?: unknown } | null)?.enabled;
752
+ if (typeof enabled !== "boolean") {
753
+ return { success: false, error: "enabled must be a boolean" };
754
+ }
755
+
756
+ try {
757
+ const nextConfig = updateSkillsConfig((current) => ({
758
+ ...current,
759
+ llm: { ...current.llm, enabled },
760
+ }));
761
+ return {
762
+ success: true,
763
+ llmEnabled: nextConfig.llm.enabled,
764
+ configPath: SKILLS_CONFIG_PATH,
765
+ };
766
+ } catch (error) {
767
+ return {
768
+ success: false,
769
+ error: `Failed to update skills config: ${error}`,
770
+ configPath: SKILLS_CONFIG_PATH,
771
+ };
772
+ }
773
+ },
774
+ });
775
+
776
+ api.on("before_prompt_build", () => {
777
+ if (!service?.hasValidIdentity() || !service?.isConnected() || !isLlmEnabled()) {
778
+ return;
779
+ }
780
+ return {
781
+ prependContext: buildFocusPrompt(),
782
+ };
783
+ });
784
+
785
+ api.on("message_received", async (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
786
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
787
+ return;
788
+ }
789
+
790
+ const agentId = ctx?.agentId || ctx?.sessionKey || "main";
791
+ const content =
792
+ typeof event?.content === "string" && event.content.trim()
793
+ ? event.content.trim()
794
+ : JSON.stringify(event ?? "new message");
795
+ const context = `[${agentId}] Received: ${content}`;
796
+ const status = isLlmEnabled()
797
+ ? await pickActionWithLlm(context)
798
+ : buildMessageFallbackStatus(context);
799
+
800
+ service.sendStatus(
801
+ status.poseType,
802
+ status.action,
803
+ status.bubble || status.action,
804
+ truncateLog(context),
805
+ );
806
+ });
807
+ },
808
+ };
809
+
810
+ export default plugin;