@openbrt/weclawbotctl 0.1.15 → 0.1.16

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 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
- When the OpenClaw tool `weclawbot_publish_screen_document` is used from a
80
- Telegram/UI session, the plugin renders the exact mono1 pages back into PNG
81
- previews and attaches them to the same session when the channel supports files.
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 plugin's automatic preview hook is only a
87
+ best-effort fallback.
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
 
@@ -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 screen <document.json> [--force] [--no-wait] [--timeout seconds]
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 { previewSummary, renderScreenDocumentPreviewPages } from "./lib/screen-preview.mjs";
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 previewPages = renderScreenDocumentPreviewPages(outbound);
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,
@@ -290,10 +291,14 @@ async function attachScreenPreview(api, event, ctx) {
290
291
  try {
291
292
  if (api.pluginConfig?.auto_preview === false) return;
292
293
  if (event?.error) return;
293
- if (!ctx?.sessionKey || typeof api.session?.workflow?.sendSessionAttachment !== "function") return;
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 === "weclawbot_publish_screen_document") {
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?.debug?.(`weclawbot preview attachment skipped: ${errorMessage(error)}`);
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) return;
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) return;
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
- await api.session.workflow.sendSessionAttachment({
332
- sessionKey: ctx.sessionKey,
333
- files,
334
- text: `WeClawBot screen preview: ${document.id || "screen"} (${pageLabel}, ${source})`,
335
- maxBytes: 2_000_000,
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
+ }
@@ -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
+ }
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "auto_preview": {
30
30
  "type": "boolean",
31
- "description": "Automatically attach PNG previews after publishing a screen document from an OpenClaw session."
31
+ "description": "Best-effort fallback that attaches PNG previews after publishing. Agents should still 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.15",
3
+ "version": "0.1.16",
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`: after
58
- publish it can attach PNG previews of the exact mono1 pages back to the
59
- conversation. Do not use OpenClaw Canvas for requests that mention WeClawBot, the
60
- physical screen, or “屏上”; Canvas is an OpenClaw UI surface, not the ESP32
61
- e-paper display. Do not send raw text to firmware. The agent owns text layout,
62
- font choice, image rasterization, and page splitting; the device consumes pixels.
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. This review loop belongs in the agent/tool layer; do not
90
- expect firmware to fix typography or split pages after the pixels arrive.
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 preview = renderScreenDocumentPreviewPages({
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
- }, { scale: 2 });
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");
@@ -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 can attach PNG previews of the exact pages back to the chat/UI after a
48
- successful publish.
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