@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 +810 -429
- package/openclaw.plugin.json +18 -24
- package/package.json +1 -1
- package/skills/focus-forwarder/SKILL.md +93 -27
- package/src/config.ts +9 -10
- package/src/service.ts +130 -80
- package/src/types.ts +103 -40
package/index.ts
CHANGED
|
@@ -1,429 +1,810 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
actions:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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;
|