@openbrt/weclawbotctl 0.1.15 → 0.1.17
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/README.md +13 -4
- package/bin/weclawbotctl.mjs +49 -3
- package/index.mjs +131 -96
- package/lib/openclaw-preview.mjs +101 -0
- package/lib/screen-preview.mjs +41 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/skills/weclawbot-curator/SKILL.md +15 -8
- package/test/openclaw-preview-hook.test.mjs +41 -0
- package/test/screen-preview.test.mjs +14 -3
- package/workspace/AGENTS.md +5 -2
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ pre-rendered `weclawbot.screen_document.v1` first. The firmware receives pixels;
|
|
|
69
69
|
it does not lay out text, choose fonts, or split pages for agents.
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
+
weclawbotctl preview /path/to/screen-document.json
|
|
72
73
|
weclawbotctl screen /path/to/screen-document.json
|
|
73
74
|
```
|
|
74
75
|
|
|
@@ -76,9 +77,14 @@ weclawbotctl screen /path/to/screen-document.json
|
|
|
76
77
|
only after the firmware reports `applied`; a firmware `rejected` status or a
|
|
77
78
|
timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
|
|
78
79
|
publish acknowledgement is enough.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
`preview` renders the exact mono1 pages into PNG files. `screen` also emits a
|
|
81
|
+
`preview.pages[].path` list by default. Agents should inspect those images when
|
|
82
|
+
possible and send them through their normal chat/media channel so the user sees
|
|
83
|
+
the actual effect, not just "published".
|
|
84
|
+
When the OpenClaw tool `weclawbot_publish_screen_document` is used, its return
|
|
85
|
+
value contains the same `preview.pages[].path` list. The agent owns delivering
|
|
86
|
+
that preview to the user. The optional automatic preview hook is off by default
|
|
87
|
+
and exists only for legacy experiments.
|
|
82
88
|
|
|
83
89
|
To clear the current note, use the firmware clear command:
|
|
84
90
|
|
|
@@ -208,6 +214,7 @@ pre-rendered screen document immediately:
|
|
|
208
214
|
|
|
209
215
|
```bash
|
|
210
216
|
weclawbotctl doctor --online
|
|
217
|
+
weclawbotctl preview /path/to/screen-document.json
|
|
211
218
|
weclawbotctl screen /path/to/screen-document.json
|
|
212
219
|
```
|
|
213
220
|
|
|
@@ -289,9 +296,11 @@ screen has exactly one page.
|
|
|
289
296
|
Before publishing, agents should inspect or otherwise self-evaluate the rendered
|
|
290
297
|
pages against the user's preferences and their own learned standards when their
|
|
291
298
|
runtime supports it. Regenerate the document if the bitmap does not satisfy those
|
|
292
|
-
standards.
|
|
299
|
+
standards. After a successful publish, send the preview PNGs to the user through
|
|
300
|
+
the current chat/media channel so the user can see the effect.
|
|
293
301
|
|
|
294
302
|
```bash
|
|
303
|
+
weclawbotctl preview /path/to/screen-document.json
|
|
295
304
|
weclawbotctl screen /path/to/screen-document.json
|
|
296
305
|
```
|
|
297
306
|
|
package/bin/weclawbotctl.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import process from "node:process";
|
|
|
10
10
|
import { validateActivity } from "../lib/activity.mjs";
|
|
11
11
|
import { validateScreenDocument } from "../lib/direct-control.mjs";
|
|
12
12
|
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "../lib/mqtt-control.mjs";
|
|
13
|
+
import { writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
|
|
15
16
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
@@ -17,7 +18,7 @@ const DEFAULT_OPENCLAW_PLUGIN_SPEC = "@openbrt/weclawbotctl";
|
|
|
17
18
|
const MIN_OPENCLAW_VERSION = "2026.6.9";
|
|
18
19
|
|
|
19
20
|
const [command, ...args] = process.argv.slice(2);
|
|
20
|
-
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "clear", "openclaw"]);
|
|
21
|
+
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "preview", "clear", "openclaw"]);
|
|
21
22
|
if (!commands.has(command)) {
|
|
22
23
|
usage();
|
|
23
24
|
process.exit(64);
|
|
@@ -30,6 +31,7 @@ try {
|
|
|
30
31
|
else if (command === "export") await commandExport(args);
|
|
31
32
|
else if (command === "unbind") await commandUnbind(args);
|
|
32
33
|
else if (command === "screen") await commandScreen(args);
|
|
34
|
+
else if (command === "preview") await commandPreview(args);
|
|
33
35
|
else if (command === "clear") await commandClear(args);
|
|
34
36
|
else if (command === "openclaw") await commandOpenClaw(args);
|
|
35
37
|
else await commandActivity(command, args);
|
|
@@ -208,10 +210,13 @@ async function commandScreen(values) {
|
|
|
208
210
|
force: false,
|
|
209
211
|
wait: true,
|
|
210
212
|
timeout: 12,
|
|
213
|
+
preview: true,
|
|
214
|
+
"preview-dir": "",
|
|
215
|
+
scale: 2,
|
|
211
216
|
});
|
|
212
217
|
const file = String(options._[0] || "").trim();
|
|
213
218
|
if (!file || options._.length !== 1) {
|
|
214
|
-
throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]");
|
|
219
|
+
throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir]");
|
|
215
220
|
}
|
|
216
221
|
const document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
217
222
|
if (options.force) {
|
|
@@ -224,6 +229,12 @@ async function commandScreen(values) {
|
|
|
224
229
|
if (!validation.ok) {
|
|
225
230
|
throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
|
|
226
231
|
}
|
|
232
|
+
const preview = options.preview
|
|
233
|
+
? await writeScreenDocumentPreviewFiles(document, {
|
|
234
|
+
outputDir: options["preview-dir"] || "",
|
|
235
|
+
scale: Number(options.scale) || 2,
|
|
236
|
+
})
|
|
237
|
+
: { available: false, pages: [] };
|
|
227
238
|
const control = {
|
|
228
239
|
schema: "weclawbot.control.v1",
|
|
229
240
|
id: `screen_${crypto.randomUUID()}`,
|
|
@@ -248,6 +259,7 @@ async function commandScreen(values) {
|
|
|
248
259
|
force_replace: document.force_replace === true,
|
|
249
260
|
warnings: validation.warnings,
|
|
250
261
|
layout_guidance: validation.layout_guidance,
|
|
262
|
+
preview,
|
|
251
263
|
status: delivery.status,
|
|
252
264
|
}));
|
|
253
265
|
return;
|
|
@@ -262,6 +274,39 @@ async function commandScreen(values) {
|
|
|
262
274
|
force_replace: document.force_replace === true,
|
|
263
275
|
warnings: validation.warnings,
|
|
264
276
|
layout_guidance: validation.layout_guidance,
|
|
277
|
+
preview,
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function commandPreview(values) {
|
|
282
|
+
const options = parseOptions(values, {
|
|
283
|
+
output: "",
|
|
284
|
+
"output-dir": "",
|
|
285
|
+
scale: 2,
|
|
286
|
+
});
|
|
287
|
+
const file = String(options._[0] || "").trim();
|
|
288
|
+
if (!file || options._.length !== 1) {
|
|
289
|
+
throw new Error("Usage: weclawbotctl preview <document.json> [--output-dir dir] [--scale 1..4]");
|
|
290
|
+
}
|
|
291
|
+
const document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
292
|
+
const validation = validateScreenDocument(document, {
|
|
293
|
+
agent_transport: { available: true, screen_document_available: true },
|
|
294
|
+
});
|
|
295
|
+
if (!validation.ok) {
|
|
296
|
+
throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
|
|
297
|
+
}
|
|
298
|
+
const outputDir = options["output-dir"] || options.output || "";
|
|
299
|
+
const preview = await writeScreenDocumentPreviewFiles(document, {
|
|
300
|
+
outputDir,
|
|
301
|
+
scale: Number(options.scale) || 2,
|
|
302
|
+
});
|
|
303
|
+
console.log(JSON.stringify({
|
|
304
|
+
ok: true,
|
|
305
|
+
id: document.id,
|
|
306
|
+
pages: document.pages.length,
|
|
307
|
+
warnings: validation.warnings,
|
|
308
|
+
layout_guidance: validation.layout_guidance,
|
|
309
|
+
preview,
|
|
265
310
|
}));
|
|
266
311
|
}
|
|
267
312
|
|
|
@@ -739,7 +784,8 @@ function usage() {
|
|
|
739
784
|
weclawbotctl unbind --yes
|
|
740
785
|
weclawbotctl thinking [--ttl seconds] [--id correlation-id]
|
|
741
786
|
weclawbotctl idle [--id correlation-id]
|
|
742
|
-
weclawbotctl
|
|
787
|
+
weclawbotctl preview <document.json> [--output-dir dir] [--scale 1..4]
|
|
788
|
+
weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir] [--no-preview]
|
|
743
789
|
weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
|
|
744
790
|
weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
|
|
745
791
|
weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
|
package/index.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
|
|
9
9
|
import { validateActivity } from "./lib/activity.mjs";
|
|
10
10
|
import { validateScreenDocument } from "./lib/direct-control.mjs";
|
|
11
11
|
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "./lib/mqtt-control.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "./lib/openclaw-preview.mjs";
|
|
13
|
+
import { renderScreenDocumentPreviewPages, writeScreenDocumentPreviewFiles } from "./lib/screen-preview.mjs";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
15
16
|
const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
|
|
@@ -114,8 +115,9 @@ const pluginEntry = defineToolPlugin({
|
|
|
114
115
|
force_replace: Type.Optional(Type.Boolean()),
|
|
115
116
|
wait_status: Type.Optional(Type.Boolean()),
|
|
116
117
|
timeout_seconds: Type.Optional(Type.Number()),
|
|
118
|
+
preview_output_dir: Type.Optional(Type.String()),
|
|
117
119
|
}, { additionalProperties: false }),
|
|
118
|
-
execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds }) => {
|
|
120
|
+
execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds, preview_output_dir }) => {
|
|
119
121
|
const outbound = cloneObject(document);
|
|
120
122
|
if (force_replace) {
|
|
121
123
|
outbound.force_replace = true;
|
|
@@ -134,8 +136,7 @@ const pluginEntry = defineToolPlugin({
|
|
|
134
136
|
document: outbound,
|
|
135
137
|
};
|
|
136
138
|
const credentials = await requireCredentials(credentials_path);
|
|
137
|
-
const
|
|
138
|
-
const preview = previewSummary(previewPages);
|
|
139
|
+
const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: preview_output_dir || "" });
|
|
139
140
|
if (wait_status !== false) {
|
|
140
141
|
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
141
142
|
expectedDetail: outbound.id,
|
|
@@ -288,12 +289,16 @@ async function finishHookActivity(api, event, ctx) {
|
|
|
288
289
|
|
|
289
290
|
async function attachScreenPreview(api, event, ctx) {
|
|
290
291
|
try {
|
|
291
|
-
if (api.pluginConfig?.auto_preview
|
|
292
|
+
if (api.pluginConfig?.auto_preview !== true) return;
|
|
292
293
|
if (event?.error) return;
|
|
293
|
-
|
|
294
|
+
const sessionKey = resolveHookSessionKey(event, ctx);
|
|
295
|
+
if (!sessionKey) {
|
|
296
|
+
logPreviewSkip(api, event, "missing sessionKey");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
294
299
|
let document = null;
|
|
295
300
|
let source = "tool";
|
|
296
|
-
if (event?.toolName
|
|
301
|
+
if (isScreenPublishTool(event?.toolName)) {
|
|
297
302
|
document = cloneObject(event.params?.document);
|
|
298
303
|
if (event.params?.force_replace) {
|
|
299
304
|
document.force_replace = true;
|
|
@@ -301,15 +306,18 @@ async function attachScreenPreview(api, event, ctx) {
|
|
|
301
306
|
}
|
|
302
307
|
} else if (isExecTool(event?.toolName)) {
|
|
303
308
|
const file = extractScreenDocumentPathFromExecParams(event.params);
|
|
309
|
+
if (!file && collectCommandStrings(event.params).some((value) => /weclawbotctl\s+screen\b/u.test(value))) {
|
|
310
|
+
logPreviewSkip(api, event, "screen command detected but document path was not found");
|
|
311
|
+
}
|
|
304
312
|
if (!file) return;
|
|
305
313
|
document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
306
314
|
source = "cli";
|
|
307
315
|
} else {
|
|
308
316
|
return;
|
|
309
317
|
}
|
|
310
|
-
await attachPreviewForDocument(api, document, ctx, source);
|
|
318
|
+
await attachPreviewForDocument(api, document, { ...ctx, sessionKey }, source);
|
|
311
319
|
} catch (error) {
|
|
312
|
-
api.logger?.
|
|
320
|
+
api.logger?.warn?.(`weclawbot preview attachment failed: ${errorMessage(error)}`);
|
|
313
321
|
}
|
|
314
322
|
}
|
|
315
323
|
|
|
@@ -317,9 +325,15 @@ async function attachPreviewForDocument(api, document, ctx, source) {
|
|
|
317
325
|
const validation = validateScreenDocument(document, {
|
|
318
326
|
agent_transport: { available: true, screen_document_available: true },
|
|
319
327
|
});
|
|
320
|
-
if (!validation.ok)
|
|
328
|
+
if (!validation.ok) {
|
|
329
|
+
api.logger?.warn?.(`weclawbot preview skipped: invalid screen document (${validation.errors?.[0] || "validation failed"})`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
321
332
|
const previewPages = renderScreenDocumentPreviewPages(document);
|
|
322
|
-
if (previewPages.length === 0)
|
|
333
|
+
if (previewPages.length === 0) {
|
|
334
|
+
api.logger?.warn?.("weclawbot preview skipped: no preview pages rendered");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
323
337
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
|
|
324
338
|
const files = [];
|
|
325
339
|
for (const page of previewPages) {
|
|
@@ -328,15 +342,115 @@ async function attachPreviewForDocument(api, document, ctx, source) {
|
|
|
328
342
|
files.push({ path: file });
|
|
329
343
|
}
|
|
330
344
|
const pageLabel = `${previewPages.length} page${previewPages.length === 1 ? "" : "s"}`;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
345
|
+
const caption = `WeClawBot screen preview: ${document.id || "screen"} (${pageLabel}, ${source})`;
|
|
346
|
+
let attached = false;
|
|
347
|
+
if (typeof api.session?.workflow?.sendSessionAttachment === "function") {
|
|
348
|
+
const result = await api.session.workflow.sendSessionAttachment({
|
|
349
|
+
sessionKey: ctx.sessionKey,
|
|
350
|
+
files,
|
|
351
|
+
text: caption,
|
|
352
|
+
maxBytes: 2_000_000,
|
|
353
|
+
captionFormat: "plain",
|
|
354
|
+
channelHints: { telegram: { forceDocumentMime: "image/png" } },
|
|
355
|
+
});
|
|
356
|
+
if (result?.ok) {
|
|
357
|
+
attached = true;
|
|
358
|
+
api.logger?.info?.(`weclawbot preview attached via session workflow: ${result.channel} count=${result.count}`);
|
|
359
|
+
} else {
|
|
360
|
+
api.logger?.warn?.(`weclawbot session attachment unavailable: ${result?.error || "unknown error"}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!attached) {
|
|
364
|
+
const result = await sendPreviewViaOutboundAdapter(api, {
|
|
365
|
+
ctx,
|
|
366
|
+
files,
|
|
367
|
+
document,
|
|
368
|
+
source,
|
|
369
|
+
caption,
|
|
370
|
+
dir,
|
|
371
|
+
});
|
|
372
|
+
if (!result?.ok) {
|
|
373
|
+
api.logger?.warn?.(`weclawbot preview outbound fallback failed: ${result?.error || "unknown error"}`);
|
|
374
|
+
} else {
|
|
375
|
+
api.logger?.info?.(`weclawbot preview attached via outbound adapter: ${result.channel} count=${result.count}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
337
378
|
scheduleRemove(dir);
|
|
338
379
|
}
|
|
339
380
|
|
|
381
|
+
async function sendPreviewViaOutboundAdapter(api, params) {
|
|
382
|
+
const delivery = resolvePreviewDeliveryContext(api, params.ctx);
|
|
383
|
+
if (!delivery?.channel || !delivery?.to) {
|
|
384
|
+
return { ok: false, error: `session has no active delivery route: ${params.ctx.sessionKey}` };
|
|
385
|
+
}
|
|
386
|
+
const outbound = await api.runtime?.channel?.outbound?.loadAdapter?.(delivery.channel);
|
|
387
|
+
if (!outbound?.sendMedia) {
|
|
388
|
+
return { ok: false, error: `channel ${delivery.channel} has no media outbound adapter` };
|
|
389
|
+
}
|
|
390
|
+
const cfg = api.runtime?.config?.current?.() || api.config;
|
|
391
|
+
let count = 0;
|
|
392
|
+
for (let index = 0; index < params.files.length; index += 1) {
|
|
393
|
+
const file = params.files[index];
|
|
394
|
+
const suffix = params.files.length > 1 ? ` p${index + 1}/${params.files.length}` : "";
|
|
395
|
+
await outbound.sendMedia({
|
|
396
|
+
cfg,
|
|
397
|
+
to: delivery.to,
|
|
398
|
+
accountId: delivery.accountId,
|
|
399
|
+
threadId: delivery.threadId,
|
|
400
|
+
text: `${params.caption}${suffix}`,
|
|
401
|
+
mediaUrl: file.path,
|
|
402
|
+
mediaLocalRoots: [params.dir],
|
|
403
|
+
mediaReadFile: async (filePath) => fs.readFile(filePath),
|
|
404
|
+
forceDocument: false,
|
|
405
|
+
silent: true,
|
|
406
|
+
});
|
|
407
|
+
count += 1;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
ok: true,
|
|
411
|
+
channel: delivery.channel,
|
|
412
|
+
deliveredTo: delivery.to,
|
|
413
|
+
count,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function resolvePreviewDeliveryContext(api, ctx) {
|
|
418
|
+
const direct = normalizeDeliveryContext(ctx?.deliveryContext);
|
|
419
|
+
if (direct) return direct;
|
|
420
|
+
const entry = getSessionEntryBestEffort(api, ctx);
|
|
421
|
+
return normalizeDeliveryContext(entry?.deliveryContext) || normalizeDeliveryContext(entry?.route);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getSessionEntryBestEffort(api, ctx) {
|
|
425
|
+
const getter = api.runtime?.agent?.session?.getSessionEntry;
|
|
426
|
+
if (typeof getter !== "function" || !ctx?.sessionKey) return null;
|
|
427
|
+
try {
|
|
428
|
+
return getter({ agentId: ctx.agentId, sessionKey: ctx.sessionKey });
|
|
429
|
+
} catch {
|
|
430
|
+
try {
|
|
431
|
+
return getter({ sessionKey: ctx.sessionKey });
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function logPreviewSkip(api, event, reason) {
|
|
439
|
+
api.logger?.warn?.(`weclawbot preview skipped: tool=${String(event?.toolName || "unknown")} reason=${reason}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function resolveHookSessionKey(event, ctx) {
|
|
443
|
+
return String(ctx?.sessionKey || event?.sessionKey || "").trim();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isScreenPublishTool(name) {
|
|
447
|
+
return String(name || "").includes("weclawbot_publish_screen_document");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isExecTool(name) {
|
|
451
|
+
return /(^|[_-])(exec|shell|command|process)($|[_-])/iu.test(String(name || ""));
|
|
452
|
+
}
|
|
453
|
+
|
|
340
454
|
function shouldAutoActivity(event, ctx) {
|
|
341
455
|
if (String(ctx?.trigger || "").includes("curator")) return false;
|
|
342
456
|
const prompt = String(event?.prompt || "");
|
|
@@ -350,80 +464,6 @@ function hookActivityKey(event, ctx) {
|
|
|
350
464
|
return "";
|
|
351
465
|
}
|
|
352
466
|
|
|
353
|
-
function isExecTool(name) {
|
|
354
|
-
return /(^|[_-])(exec|shell|command)($|[_-])/iu.test(String(name || ""));
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function extractScreenDocumentPathFromExecParams(params) {
|
|
358
|
-
const command = collectCommandStrings(params).join("\n");
|
|
359
|
-
if (!/weclawbotctl\s+screen\b/u.test(command)) return "";
|
|
360
|
-
for (const line of command.split(/\r?\n/u)) {
|
|
361
|
-
const match = line.match(/(?:^|\s)(?:[^\s;&|]*\/)?weclawbotctl\s+screen\b([^;&|\n]*)/u);
|
|
362
|
-
if (!match) continue;
|
|
363
|
-
const tokens = shellSplit(match[1] || "");
|
|
364
|
-
const candidates = [];
|
|
365
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
366
|
-
const token = tokens[index];
|
|
367
|
-
if (!token) continue;
|
|
368
|
-
if (token.startsWith("--")) {
|
|
369
|
-
const key = token.split("=", 1)[0];
|
|
370
|
-
if (!token.includes("=") && new Set(["--credentials", "--timeout"]).has(key)) index += 1;
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
candidates.push(token);
|
|
374
|
-
}
|
|
375
|
-
const picked = candidates.findLast((token) => token.endsWith(".json")) || candidates.at(-1) || "";
|
|
376
|
-
if (picked) return expandPathWithBase(picked, params?.cwd || params?.workdir || process.cwd());
|
|
377
|
-
}
|
|
378
|
-
return "";
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function collectCommandStrings(value, depth = 0) {
|
|
382
|
-
if (depth > 3 || value == null) return [];
|
|
383
|
-
if (typeof value === "string") return [value];
|
|
384
|
-
if (Array.isArray(value)) return value.flatMap((item) => collectCommandStrings(item, depth + 1));
|
|
385
|
-
if (typeof value !== "object") return [];
|
|
386
|
-
const wanted = ["cmd", "command", "script", "input", "args", "argv"];
|
|
387
|
-
return wanted.flatMap((key) => collectCommandStrings(value[key], depth + 1));
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function shellSplit(value) {
|
|
391
|
-
const tokens = [];
|
|
392
|
-
let token = "";
|
|
393
|
-
let quote = "";
|
|
394
|
-
let escaped = false;
|
|
395
|
-
for (const ch of String(value || "")) {
|
|
396
|
-
if (escaped) {
|
|
397
|
-
token += ch;
|
|
398
|
-
escaped = false;
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
if (ch === "\\") {
|
|
402
|
-
escaped = true;
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
if (quote) {
|
|
406
|
-
if (ch === quote) quote = "";
|
|
407
|
-
else token += ch;
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
if (ch === "'" || ch === "\"") {
|
|
411
|
-
quote = ch;
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
if (/\s/u.test(ch)) {
|
|
415
|
-
if (token) {
|
|
416
|
-
tokens.push(token);
|
|
417
|
-
token = "";
|
|
418
|
-
}
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
token += ch;
|
|
422
|
-
}
|
|
423
|
-
if (token) tokens.push(token);
|
|
424
|
-
return tokens;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
467
|
async function requireCredentials(credentialsPath) {
|
|
428
468
|
const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
|
|
429
469
|
const payload = await readCredentials(file);
|
|
@@ -445,11 +485,6 @@ function expandPath(value) {
|
|
|
445
485
|
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
446
486
|
}
|
|
447
487
|
|
|
448
|
-
function expandPathWithBase(value, base) {
|
|
449
|
-
const expanded = expandPath(value);
|
|
450
|
-
return path.isAbsolute(expanded) ? expanded : path.resolve(String(base || process.cwd()), expanded);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
488
|
function cloneObject(value) {
|
|
454
489
|
if (!value || typeof value !== "object") throw new Error("document must be an object");
|
|
455
490
|
return JSON.parse(JSON.stringify(value));
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function extractScreenDocumentPathFromExecParams(params) {
|
|
5
|
+
const command = collectCommandStrings(params).join("\n");
|
|
6
|
+
if (!/weclawbotctl\s+screen\b/u.test(command)) return "";
|
|
7
|
+
for (const line of command.split(/\r?\n/u)) {
|
|
8
|
+
const match = line.match(/(?:^|\s)(?:[^\s;&|]*\/)?weclawbotctl\s+screen\b([^;&|\n]*)/u);
|
|
9
|
+
if (!match) continue;
|
|
10
|
+
const tokens = shellSplit(match[1] || "");
|
|
11
|
+
const candidates = [];
|
|
12
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
13
|
+
const token = tokens[index];
|
|
14
|
+
if (!token) continue;
|
|
15
|
+
if (token.startsWith("--")) {
|
|
16
|
+
const key = token.split("=", 1)[0];
|
|
17
|
+
if (!token.includes("=") && new Set(["--credentials", "--timeout"]).has(key)) index += 1;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
candidates.push(token);
|
|
21
|
+
}
|
|
22
|
+
const picked = candidates.findLast((token) => token.endsWith(".json")) || candidates.at(-1) || "";
|
|
23
|
+
if (picked) return expandPathWithBase(picked, params?.cwd || params?.workdir || process.cwd());
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectCommandStrings(value, depth = 0) {
|
|
29
|
+
if (depth > 5 || value == null) return [];
|
|
30
|
+
if (typeof value === "string") return value.length <= 40_000 ? [value] : [];
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
if (value.every((item) => typeof item === "string")) return [value.join(" ")];
|
|
33
|
+
return value.flatMap((item) => collectCommandStrings(item, depth + 1));
|
|
34
|
+
}
|
|
35
|
+
if (typeof value !== "object") return [];
|
|
36
|
+
return Object.values(value).flatMap((item) => collectCommandStrings(item, depth + 1));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeDeliveryContext(value) {
|
|
40
|
+
if (!value || typeof value !== "object") return null;
|
|
41
|
+
const channel = typeof value.channel === "string" ? value.channel : "";
|
|
42
|
+
const to = typeof value.to === "string"
|
|
43
|
+
? value.to
|
|
44
|
+
: typeof value.target?.to === "string"
|
|
45
|
+
? value.target.to
|
|
46
|
+
: "";
|
|
47
|
+
if (!channel || !to) return null;
|
|
48
|
+
return {
|
|
49
|
+
channel,
|
|
50
|
+
to,
|
|
51
|
+
accountId: typeof value.accountId === "string" ? value.accountId : undefined,
|
|
52
|
+
threadId: value.threadId,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shellSplit(value) {
|
|
57
|
+
const tokens = [];
|
|
58
|
+
let token = "";
|
|
59
|
+
let quote = "";
|
|
60
|
+
let escaped = false;
|
|
61
|
+
for (const ch of String(value || "")) {
|
|
62
|
+
if (escaped) {
|
|
63
|
+
token += ch;
|
|
64
|
+
escaped = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === "\\") {
|
|
68
|
+
escaped = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (quote) {
|
|
72
|
+
if (ch === quote) quote = "";
|
|
73
|
+
else token += ch;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === "'" || ch === "\"") {
|
|
77
|
+
quote = ch;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/\s/u.test(ch)) {
|
|
81
|
+
if (token) {
|
|
82
|
+
tokens.push(token);
|
|
83
|
+
token = "";
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
token += ch;
|
|
88
|
+
}
|
|
89
|
+
if (token) tokens.push(token);
|
|
90
|
+
return tokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function expandPathWithBase(value, base) {
|
|
94
|
+
const expanded = expandPath(value);
|
|
95
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(String(base || process.cwd()), expanded);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function expandPath(value) {
|
|
99
|
+
const raw = String(value || "");
|
|
100
|
+
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
101
|
+
}
|
package/lib/screen-preview.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import { deflateSync } from "node:zlib";
|
|
3
6
|
|
|
4
7
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -35,6 +38,35 @@ export function previewSummary(previewPages) {
|
|
|
35
38
|
};
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
export async function writeScreenDocumentPreviewFiles(document, options = {}) {
|
|
42
|
+
const previewPages = renderScreenDocumentPreviewPages(document, options);
|
|
43
|
+
const outputDir = options.outputDir
|
|
44
|
+
? expandPath(options.outputDir)
|
|
45
|
+
: await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
|
|
46
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
47
|
+
const basename = safeFilename(options.basename || document?.id || "screen");
|
|
48
|
+
const pages = [];
|
|
49
|
+
for (const page of previewPages) {
|
|
50
|
+
const file = path.join(outputDir, `${basename}-p${page.index + 1}.png`);
|
|
51
|
+
await fs.writeFile(file, page.png);
|
|
52
|
+
pages.push({
|
|
53
|
+
index: page.index,
|
|
54
|
+
path: file,
|
|
55
|
+
width: page.width,
|
|
56
|
+
height: page.height,
|
|
57
|
+
scale: page.scale,
|
|
58
|
+
bytes: page.bytes,
|
|
59
|
+
sha256: page.sha256,
|
|
60
|
+
mime_type: "image/png",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
available: pages.length > 0,
|
|
65
|
+
output_dir: outputDir,
|
|
66
|
+
pages,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
function renderMonoPagePreview(page, options = {}) {
|
|
39
71
|
const width = Number(page?.width);
|
|
40
72
|
const height = Number(page?.height);
|
|
@@ -123,3 +155,12 @@ function clampInteger(value, min, max) {
|
|
|
123
155
|
if (!Number.isFinite(parsed)) return min;
|
|
124
156
|
return Math.max(min, Math.min(max, parsed));
|
|
125
157
|
}
|
|
158
|
+
|
|
159
|
+
function expandPath(value) {
|
|
160
|
+
const raw = String(value || "");
|
|
161
|
+
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeFilename(value) {
|
|
165
|
+
return String(value || "screen").replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 80) || "screen";
|
|
166
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"auto_preview": {
|
|
30
30
|
"type": "boolean",
|
|
31
|
-
"description": "
|
|
31
|
+
"description": "Optional legacy fallback that attaches PNG previews after publishing. Defaults off; agents should send preview.pages[].path to users explicitly."
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openbrt/weclawbotctl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "WeClawBot pairing and screen-control CLI for local AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"./package.json": "./package.json"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
|
-
"check": "node --check index.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs"
|
|
50
|
+
"check": "node --check index.mjs && node --check lib/openclaw-preview.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs test/openclaw-preview-hook.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"mqtt": "^5.10.4",
|
|
@@ -54,12 +54,17 @@ weclawbotctl screen /path/to/screen-document.json
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
or call `weclawbot_publish_screen_document` with the same document. Inside
|
|
57
|
-
OpenClaw Telegram/UI sessions, prefer `weclawbot_publish_screen_document`:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
OpenClaw Telegram/UI sessions, prefer `weclawbot_publish_screen_document`: its
|
|
58
|
+
result includes `preview.pages[].path` PNG files for the exact mono1 pages. The
|
|
59
|
+
agent must inspect those preview files when possible, then send the preview PNGs
|
|
60
|
+
back to the user through its normal chat/media channel before or alongside the
|
|
61
|
+
"已上屏" reply. If the agent used CLI instead, run `weclawbotctl preview <doc>`
|
|
62
|
+
before publishing, or read the `preview` field emitted by `weclawbotctl screen`.
|
|
63
|
+
Do not use OpenClaw Canvas for requests that
|
|
64
|
+
mention WeClawBot, the physical screen, or “屏上”; Canvas is an OpenClaw UI
|
|
65
|
+
surface, not the ESP32 e-paper display. Do not send raw text to firmware. The
|
|
66
|
+
agent owns text layout, font choice, image rasterization, preview review, user
|
|
67
|
+
visible proof, and page splitting; the device consumes pixels.
|
|
63
68
|
|
|
64
69
|
For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
|
|
65
70
|
current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
|
|
@@ -86,8 +91,10 @@ Before publishing, review the actual rendered bitmap pages when your runtime can
|
|
|
86
91
|
inspect images. Judge the preview against the user's preferences and the agent's
|
|
87
92
|
own learned standards: legibility, margins, crowding, page count, and continuity
|
|
88
93
|
across pages. If the preview does not satisfy those standards, regenerate the
|
|
89
|
-
pages before publishing.
|
|
90
|
-
|
|
94
|
+
pages before publishing. After a successful publish, include the preview PNG in
|
|
95
|
+
the user-visible response so the user can see what was sent. This review and
|
|
96
|
+
proof loop belongs in the agent/tool layer; do not expect firmware to fix
|
|
97
|
+
typography, show chat previews, or split pages after the pixels arrive.
|
|
91
98
|
|
|
92
99
|
`weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
|
|
93
100
|
device status topic by default. Treat only `applied` as success. If the device
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "../lib/openclaw-preview.mjs";
|
|
5
|
+
|
|
6
|
+
const extracted = extractScreenDocumentPathFromExecParams({
|
|
7
|
+
command: "~/.npm-global/bin/weclawbotctl screen /tmp/weclawbot-musk.json --force --timeout 20",
|
|
8
|
+
cwd: "/home/csc/.openclaw/workspace",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
assert.equal(extracted, "/tmp/weclawbot-musk.json");
|
|
12
|
+
|
|
13
|
+
const nested = collectCommandStrings({
|
|
14
|
+
payload: {
|
|
15
|
+
args: ["weclawbotctl", "screen", "relative-doc.json", "--timeout", "20"],
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.ok(nested.some((value) => value.includes("weclawbotctl screen relative-doc.json")));
|
|
20
|
+
|
|
21
|
+
const relative = extractScreenDocumentPathFromExecParams({
|
|
22
|
+
payload: {
|
|
23
|
+
input: "weclawbotctl screen relative-doc.json --force",
|
|
24
|
+
},
|
|
25
|
+
cwd: "/home/csc/.openclaw/workspace",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assert.equal(relative, path.resolve("/home/csc/.openclaw/workspace", "relative-doc.json"));
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(normalizeDeliveryContext({
|
|
31
|
+
channel: "telegram",
|
|
32
|
+
target: { to: "telegram:5728815108" },
|
|
33
|
+
accountId: "default",
|
|
34
|
+
}), {
|
|
35
|
+
channel: "telegram",
|
|
36
|
+
to: "telegram:5728815108",
|
|
37
|
+
accountId: "default",
|
|
38
|
+
threadId: undefined,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
console.log("openclaw preview hook helpers: ok");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
2
3
|
|
|
3
|
-
import { renderScreenDocumentPreviewPages, previewSummary } from "../lib/screen-preview.mjs";
|
|
4
|
+
import { renderScreenDocumentPreviewPages, previewSummary, writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
|
|
4
5
|
|
|
5
6
|
const width = 16;
|
|
6
7
|
const height = 8;
|
|
@@ -10,7 +11,7 @@ for (let i = 0; i < Math.min(width, height); i += 1) {
|
|
|
10
11
|
bytes[i * stride + (i >> 3)] |= 0x80 >> (i & 7);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const document = {
|
|
14
15
|
schema: "weclawbot.screen_document.v1",
|
|
15
16
|
pages: [{
|
|
16
17
|
format: "mono1",
|
|
@@ -19,7 +20,9 @@ const preview = renderScreenDocumentPreviewPages({
|
|
|
19
20
|
stride,
|
|
20
21
|
data_b64: bytes.toString("base64"),
|
|
21
22
|
}],
|
|
22
|
-
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const preview = renderScreenDocumentPreviewPages(document, { scale: 2 });
|
|
23
26
|
|
|
24
27
|
assert.equal(preview.length, 1);
|
|
25
28
|
assert.equal(preview[0].width, width * 2);
|
|
@@ -32,4 +35,12 @@ const summary = previewSummary(preview);
|
|
|
32
35
|
assert.equal(summary.available, true);
|
|
33
36
|
assert.equal(summary.pages[0].mime_type, "image/png");
|
|
34
37
|
|
|
38
|
+
const files = await writeScreenDocumentPreviewFiles(document, { scale: 2 });
|
|
39
|
+
assert.equal(files.available, true);
|
|
40
|
+
assert.equal(files.pages.length, 1);
|
|
41
|
+
assert.match(files.pages[0].path, /\.png$/u);
|
|
42
|
+
const persisted = await fs.readFile(files.pages[0].path);
|
|
43
|
+
assert.equal(persisted.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
|
|
44
|
+
await fs.rm(files.output_dir, { recursive: true, force: true });
|
|
45
|
+
|
|
35
46
|
console.log("screen-preview renderer: ok");
|
package/workspace/AGENTS.md
CHANGED
|
@@ -44,8 +44,11 @@ screen revision in `base_revision`.
|
|
|
44
44
|
|
|
45
45
|
When running inside OpenClaw with the WeClawBot plugin tools available, prefer
|
|
46
46
|
`weclawbot_publish_screen_document` over shelling out to `weclawbotctl screen`;
|
|
47
|
-
the tool
|
|
48
|
-
|
|
47
|
+
the tool returns `preview.pages[].path` PNG files for the exact pages. Inspect
|
|
48
|
+
those preview images when possible, and send them back to the user through the
|
|
49
|
+
normal chat/media channel before or alongside the success reply. If using CLI,
|
|
50
|
+
run `weclawbotctl preview <document.json>` before publishing, or read the
|
|
51
|
+
`preview` field emitted by `weclawbotctl screen`.
|
|
49
52
|
|
|
50
53
|
The firmware receives pixels. Do not send raw text to firmware, and do not
|
|
51
54
|
answer that direct delivery is unavailable before checking the local
|