@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 +425 -446
- package/openclaw.plugin.json +14 -19
- package/package.json +1 -1
- package/skills/focus-forwarder/SKILL.md +6 -34
- package/src/config.ts +9 -10
- package/src/service.ts +2 -2
- package/src/types.ts +0 -6
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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))
|
|
55
|
-
|
|
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)
|
|
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(
|
|
81
|
+
pluginApi?.logger.debug("[focus] loaded skills config");
|
|
99
82
|
}
|
|
100
83
|
return cachedConfig!;
|
|
101
84
|
}
|
|
102
|
-
} catch (
|
|
103
|
-
pluginApi?.logger.warn(`[focus]
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
let
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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)
|
|
169
|
+
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
|
332
170
|
}
|
|
333
171
|
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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(
|
|
434
|
-
|
|
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: {
|
|
447
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
499
|
-
const
|
|
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 {
|
|
484
|
+
return {
|
|
485
|
+
success: false,
|
|
486
|
+
error: `Unknown action "${action}" for poseType "${poseType}"`,
|
|
487
|
+
available: poseActions,
|
|
488
|
+
};
|
|
503
489
|
}
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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:
|
|
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: {
|
|
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 =
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
23
|
+
"enabled": { "label": "Enable Plugin" }
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -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.
|
|
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` -
|
|
165
|
+
- `skills-config.json` - allowed action lists used by `focus_action`
|
|
183
166
|
|
|
184
167
|
## Skills Config
|
|
185
168
|
|
|
186
|
-
Custom actions
|
|
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
|
-
-
|
|
212
|
-
-
|
|
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://
|
|
7
|
-
enabled: config.enabled ?? true,
|
|
8
|
-
|
|
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:
|
|
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
|
};
|