@openbrt/weclawbotctl 0.1.14 → 0.1.15

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
@@ -57,6 +57,12 @@ rejects stale or unrelated `idle` messages and keeps the visible thinking state.
57
57
  The bundled OpenClaw bridge publishes `thinking` before every curator job and
58
58
  `idle` after it finishes, so WeChat-origin official-mode work also gets a visible
59
59
  processing state without relying on the model to remember it.
60
+ In OpenClaw itself, the plugin also registers hooks for direct Telegram/UI agent
61
+ turns that mention WeClawBot or the physical screen. Those hooks show the
62
+ thinking pet while the turn runs and clear it before the final answer. The
63
+ installer enables `plugins.entries.weclawbot.hooks.allowConversationAccess`
64
+ because OpenClaw blocks conversation hooks for third-party plugins unless the
65
+ user explicitly grants that permission.
60
66
 
61
67
  To put text, status, diagrams, or images on the screen, render them into a
62
68
  pre-rendered `weclawbot.screen_document.v1` first. The firmware receives pixels;
@@ -70,6 +76,9 @@ weclawbotctl screen /path/to/screen-document.json
70
76
  only after the firmware reports `applied`; a firmware `rejected` status or a
71
77
  timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
72
78
  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.
73
82
 
74
83
  To clear the current note, use the firmware clear command:
75
84
 
@@ -125,7 +134,7 @@ openclaw plugins enable weclawbot
125
134
 
126
135
  Restart the OpenClaw gateway or app after installation so it reloads plugin
127
136
  tools. The doctor checks the OpenClaw version, plugin installation, plugin
128
- diagnostics, and local gateway reachability. If a local WSS gateway uses a
137
+ diagnostics, hook permission, and local gateway reachability. If a local WSS gateway uses a
129
138
  self-signed certificate, the doctor will suggest `NODE_EXTRA_CA_CERTS`. If the
130
139
  certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
131
140
  or use a certificate trusted by Node. The package does not rewrite other
@@ -361,6 +361,13 @@ async function commandOpenClawInstall(values) {
361
361
  if (options.force) installArgs.push("--force");
362
362
  await runRequired(openclaw, installArgs);
363
363
  await runRequired(openclaw, ["plugins", "enable", "weclawbot"]);
364
+ await runRequired(openclaw, [
365
+ "config",
366
+ "set",
367
+ "plugins.entries.weclawbot.hooks.allowConversationAccess",
368
+ "true",
369
+ "--strict-json",
370
+ ]);
364
371
  console.log("OpenClaw plugin installed and enabled. Restart the OpenClaw gateway or app so it reloads plugins.");
365
372
  if (options.doctor) {
366
373
  await commandOpenClawDoctor(["--bin", openclaw, "--gateway=false"]);
@@ -402,6 +409,22 @@ async function commandOpenClawDoctor(values) {
402
409
  hint: weclawbotIssue ? "Upgrade and reinstall: weclawbotctl openclaw install" : "",
403
410
  });
404
411
 
412
+ const hooksAccess = await runCaptured(openclaw, [
413
+ "config",
414
+ "get",
415
+ "plugins.entries.weclawbot.hooks.allowConversationAccess",
416
+ "--json",
417
+ ], { timeoutMs });
418
+ const hooksEnabled = hooksAccess.code === 0 && /^\s*true\s*$/iu.test(hooksAccess.stdout);
419
+ checks.push({
420
+ name: "openclaw_weclawbot_hooks",
421
+ ok: hooksEnabled,
422
+ detail: hooksEnabled
423
+ ? "conversation hooks enabled for automatic thinking state"
424
+ : compactText(hooksAccess.stderr || hooksAccess.stdout || "hooks.allowConversationAccess is not enabled"),
425
+ hint: hooksEnabled ? "" : "Run: openclaw config set plugins.entries.weclawbot.hooks.allowConversationAccess true --strict-json",
426
+ });
427
+
405
428
  if (options.gateway) {
406
429
  const gatewayEnv = { ...process.env };
407
430
  const defaultCert = path.join(os.homedir(), ".openclaw", "gateway", "tls", "gateway-cert.pem");
package/index.mjs CHANGED
@@ -9,16 +9,23 @@ 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
13
 
13
14
  const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
15
+ const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
16
+ const activeRunActivities = new Map();
14
17
 
15
18
  // The long-running curator bridge remains a separate service. These tools keep
16
19
  // the local agent path explicit: validate first, then publish only with the
17
20
  // user's paired MQTT credential.
18
- export default defineToolPlugin({
21
+ const pluginEntry = defineToolPlugin({
19
22
  id: "weclawbot",
20
23
  name: "WeClawBot",
21
24
  description: "WeClawBot screen-curation skill pack and direct MQTT control tools.",
25
+ configSchema: Type.Object({
26
+ auto_activity: Type.Optional(Type.Boolean()),
27
+ auto_preview: Type.Optional(Type.Boolean()),
28
+ }, { additionalProperties: false }),
22
29
  tools: (tool) => [
23
30
  tool({
24
31
  name: "weclawbot_status",
@@ -127,6 +134,8 @@ export default defineToolPlugin({
127
134
  document: outbound,
128
135
  };
129
136
  const credentials = await requireCredentials(credentials_path);
137
+ const previewPages = renderScreenDocumentPreviewPages(outbound);
138
+ const preview = previewSummary(previewPages);
130
139
  if (wait_status !== false) {
131
140
  const delivery = await publishControlAndWaitStatus(credentials, control, {
132
141
  expectedDetail: outbound.id,
@@ -142,6 +151,7 @@ export default defineToolPlugin({
142
151
  force_replace: outbound.force_replace === true,
143
152
  warnings: validation.warnings,
144
153
  layout_guidance: validation.layout_guidance,
154
+ preview,
145
155
  status: delivery.status,
146
156
  };
147
157
  }
@@ -155,6 +165,7 @@ export default defineToolPlugin({
155
165
  force_replace: outbound.force_replace === true,
156
166
  warnings: validation.warnings,
157
167
  layout_guidance: validation.layout_guidance,
168
+ preview,
158
169
  };
159
170
  },
160
171
  }),
@@ -201,6 +212,218 @@ export default defineToolPlugin({
201
212
  ],
202
213
  });
203
214
 
215
+ const registerTools = pluginEntry.register;
216
+ pluginEntry.register = (api) => {
217
+ registerTools(api);
218
+ registerOpenClawHooks(api);
219
+ };
220
+
221
+ export default pluginEntry;
222
+
223
+ function registerOpenClawHooks(api) {
224
+ if (!api || typeof api.on !== "function") return;
225
+ api.on("before_agent_run", async (event, ctx) => {
226
+ await startHookActivity(api, event, ctx);
227
+ return { outcome: "pass" };
228
+ }, { timeoutMs: 5_000 });
229
+ api.on("before_agent_finalize", async (event, ctx) => {
230
+ await finishHookActivity(api, event, ctx);
231
+ return { action: "continue" };
232
+ }, { timeoutMs: 5_000 });
233
+ api.on("agent_end", async (event, ctx) => {
234
+ await finishHookActivity(api, event, ctx);
235
+ }, { timeoutMs: 5_000 });
236
+ api.on("after_tool_call", async (event, ctx) => {
237
+ await attachScreenPreview(api, event, ctx);
238
+ }, { timeoutMs: 10_000 });
239
+ }
240
+
241
+ async function startHookActivity(api, event, ctx) {
242
+ try {
243
+ if (api.pluginConfig?.auto_activity === false) return;
244
+ if (!shouldAutoActivity(event, ctx)) return;
245
+ const key = hookActivityKey(event, ctx);
246
+ if (!key || activeRunActivities.has(key)) return;
247
+ const correlationId = `openclaw-${sanitizeId(ctx?.runId || key).slice(0, 72)}`;
248
+ await publishControl(await requireCredentials(), {
249
+ schema: "weclawbot.control.v1",
250
+ id: `activity_${crypto.randomUUID()}`,
251
+ kind: "activity",
252
+ activity: {
253
+ schema: "weclawbot.activity.v1",
254
+ state: "thinking",
255
+ correlation_id: correlationId,
256
+ ttl_seconds: 120,
257
+ },
258
+ });
259
+ activeRunActivities.set(key, { correlationId, startedAt: Date.now() });
260
+ api.logger?.info?.(`weclawbot activity thinking sent for ${key}`);
261
+ } catch (error) {
262
+ api.logger?.debug?.(`weclawbot activity hook skipped: ${errorMessage(error)}`);
263
+ }
264
+ }
265
+
266
+ async function finishHookActivity(api, event, ctx) {
267
+ try {
268
+ const key = hookActivityKey(event, ctx);
269
+ if (!key) return;
270
+ const active = activeRunActivities.get(key);
271
+ if (!active) return;
272
+ activeRunActivities.delete(key);
273
+ await publishControl(await requireCredentials(), {
274
+ schema: "weclawbot.control.v1",
275
+ id: `activity_${crypto.randomUUID()}`,
276
+ kind: "activity",
277
+ activity: {
278
+ schema: "weclawbot.activity.v1",
279
+ state: "idle",
280
+ correlation_id: active.correlationId,
281
+ },
282
+ });
283
+ api.logger?.info?.(`weclawbot activity idle sent for ${key}`);
284
+ } catch (error) {
285
+ api.logger?.debug?.(`weclawbot activity idle hook skipped: ${errorMessage(error)}`);
286
+ }
287
+ }
288
+
289
+ async function attachScreenPreview(api, event, ctx) {
290
+ try {
291
+ if (api.pluginConfig?.auto_preview === false) return;
292
+ if (event?.error) return;
293
+ if (!ctx?.sessionKey || typeof api.session?.workflow?.sendSessionAttachment !== "function") return;
294
+ let document = null;
295
+ let source = "tool";
296
+ if (event?.toolName === "weclawbot_publish_screen_document") {
297
+ document = cloneObject(event.params?.document);
298
+ if (event.params?.force_replace) {
299
+ document.force_replace = true;
300
+ document.base_revision = "*";
301
+ }
302
+ } else if (isExecTool(event?.toolName)) {
303
+ const file = extractScreenDocumentPathFromExecParams(event.params);
304
+ if (!file) return;
305
+ document = JSON.parse(await fs.readFile(file, "utf8"));
306
+ source = "cli";
307
+ } else {
308
+ return;
309
+ }
310
+ await attachPreviewForDocument(api, document, ctx, source);
311
+ } catch (error) {
312
+ api.logger?.debug?.(`weclawbot preview attachment skipped: ${errorMessage(error)}`);
313
+ }
314
+ }
315
+
316
+ async function attachPreviewForDocument(api, document, ctx, source) {
317
+ const validation = validateScreenDocument(document, {
318
+ agent_transport: { available: true, screen_document_available: true },
319
+ });
320
+ if (!validation.ok) return;
321
+ const previewPages = renderScreenDocumentPreviewPages(document);
322
+ if (previewPages.length === 0) return;
323
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
324
+ const files = [];
325
+ for (const page of previewPages) {
326
+ const file = path.join(dir, `${safeFilename(document.id || "screen")}-p${page.index + 1}.png`);
327
+ await fs.writeFile(file, page.png);
328
+ files.push({ path: file });
329
+ }
330
+ 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
+ });
337
+ scheduleRemove(dir);
338
+ }
339
+
340
+ function shouldAutoActivity(event, ctx) {
341
+ if (String(ctx?.trigger || "").includes("curator")) return false;
342
+ const prompt = String(event?.prompt || "");
343
+ if (prompt.includes("WECLAWBOT_CURATOR_EVENT")) return false;
344
+ return SCREEN_PROMPT_PATTERN.test(prompt);
345
+ }
346
+
347
+ function hookActivityKey(event, ctx) {
348
+ if (ctx?.runId || event?.runId) return `run:${ctx?.runId || event?.runId}`;
349
+ if (ctx?.sessionKey) return `session:${ctx.sessionKey}`;
350
+ return "";
351
+ }
352
+
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
+
204
427
  async function requireCredentials(credentialsPath) {
205
428
  const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
206
429
  const payload = await readCredentials(file);
@@ -222,6 +445,11 @@ function expandPath(value) {
222
445
  return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
223
446
  }
224
447
 
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
+
225
453
  function cloneObject(value) {
226
454
  if (!value || typeof value !== "object") throw new Error("document must be an object");
227
455
  return JSON.parse(JSON.stringify(value));
@@ -239,6 +467,25 @@ function clearStatusDetail(target) {
239
467
  return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
240
468
  }
241
469
 
470
+ function sanitizeId(value) {
471
+ return String(value || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/gu, "_");
472
+ }
473
+
474
+ function safeFilename(value) {
475
+ return sanitizeId(value).slice(0, 80) || "screen";
476
+ }
477
+
478
+ function scheduleRemove(dir) {
479
+ const timer = setTimeout(() => {
480
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
481
+ }, 60_000);
482
+ timer.unref?.();
483
+ }
484
+
485
+ function errorMessage(error) {
486
+ return String(error instanceof Error ? error.message : error).replace(/\s+/gu, " ").trim().slice(0, 240);
487
+ }
488
+
242
489
  function maskedMqtt(config, topics) {
243
490
  return {
244
491
  url: config.url,
@@ -0,0 +1,125 @@
1
+ import crypto from "node:crypto";
2
+ import { deflateSync } from "node:zlib";
3
+
4
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
5
+
6
+ export function renderScreenDocumentPreviewPages(document, options = {}) {
7
+ const pages = Array.isArray(document?.pages) ? document.pages : [];
8
+ const scale = clampInteger(options.scale ?? 2, 1, 4);
9
+ return pages.map((page, index) => {
10
+ const png = renderMonoPagePreview(page, { scale });
11
+ return {
12
+ index,
13
+ width: Number(page.width) * scale,
14
+ height: Number(page.height) * scale,
15
+ scale,
16
+ png,
17
+ bytes: png.length,
18
+ sha256: crypto.createHash("sha256").update(png).digest("hex"),
19
+ };
20
+ });
21
+ }
22
+
23
+ export function previewSummary(previewPages) {
24
+ return {
25
+ available: previewPages.length > 0,
26
+ pages: previewPages.map((page) => ({
27
+ index: page.index,
28
+ width: page.width,
29
+ height: page.height,
30
+ scale: page.scale,
31
+ bytes: page.bytes,
32
+ sha256: page.sha256,
33
+ mime_type: "image/png",
34
+ })),
35
+ };
36
+ }
37
+
38
+ function renderMonoPagePreview(page, options = {}) {
39
+ const width = Number(page?.width);
40
+ const height = Number(page?.height);
41
+ const stride = Number(page?.stride);
42
+ const scale = clampInteger(options.scale ?? 2, 1, 4);
43
+ if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
44
+ throw new Error("preview page width/height must be positive integers");
45
+ }
46
+ if (!Number.isInteger(stride) || stride < Math.ceil(width / 8)) {
47
+ throw new Error("preview page stride is invalid");
48
+ }
49
+ const packed = Buffer.from(String(page?.data_b64 || ""), "base64");
50
+ if (packed.length < stride * height) {
51
+ throw new Error("preview page data is shorter than stride * height");
52
+ }
53
+
54
+ const outWidth = width * scale;
55
+ const outHeight = height * scale;
56
+ const gray = Buffer.alloc(outWidth * outHeight, 0xff);
57
+ for (let y = 0; y < height; y += 1) {
58
+ const row = y * stride;
59
+ for (let x = 0; x < width; x += 1) {
60
+ const black = (packed[row + (x >> 3)] & (0x80 >> (x & 7))) !== 0;
61
+ if (!black) continue;
62
+ for (let dy = 0; dy < scale; dy += 1) {
63
+ const outRow = (y * scale + dy) * outWidth;
64
+ for (let dx = 0; dx < scale; dx += 1) {
65
+ gray[outRow + x * scale + dx] = 0;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return encodeGrayscalePng(gray, outWidth, outHeight);
71
+ }
72
+
73
+ function encodeGrayscalePng(gray, width, height) {
74
+ const raw = Buffer.alloc((width + 1) * height);
75
+ for (let y = 0; y < height; y += 1) {
76
+ const row = y * (width + 1);
77
+ raw[row] = 0;
78
+ gray.copy(raw, row + 1, y * width, (y + 1) * width);
79
+ }
80
+ const ihdr = Buffer.alloc(13);
81
+ ihdr.writeUInt32BE(width, 0);
82
+ ihdr.writeUInt32BE(height, 4);
83
+ ihdr[8] = 8;
84
+ ihdr[9] = 0;
85
+ ihdr[10] = 0;
86
+ ihdr[11] = 0;
87
+ ihdr[12] = 0;
88
+ return Buffer.concat([
89
+ PNG_SIGNATURE,
90
+ pngChunk("IHDR", ihdr),
91
+ pngChunk("IDAT", deflateSync(raw)),
92
+ pngChunk("IEND", Buffer.alloc(0)),
93
+ ]);
94
+ }
95
+
96
+ function pngChunk(type, data) {
97
+ const typeBuf = Buffer.from(type, "ascii");
98
+ const len = Buffer.alloc(4);
99
+ len.writeUInt32BE(data.length, 0);
100
+ const crc = Buffer.alloc(4);
101
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
102
+ return Buffer.concat([len, typeBuf, data, crc]);
103
+ }
104
+
105
+ const crcTable = new Uint32Array(256).map((_, index) => {
106
+ let c = index;
107
+ for (let bit = 0; bit < 8; bit += 1) {
108
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
109
+ }
110
+ return c >>> 0;
111
+ });
112
+
113
+ function crc32(buffer) {
114
+ let crc = 0xffffffff;
115
+ for (const byte of buffer) {
116
+ crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
117
+ }
118
+ return (crc ^ 0xffffffff) >>> 0;
119
+ }
120
+
121
+ function clampInteger(value, min, max) {
122
+ const parsed = Number.parseInt(String(value), 10);
123
+ if (!Number.isFinite(parsed)) return min;
124
+ return Math.max(min, Math.min(max, parsed));
125
+ }
@@ -21,6 +21,15 @@
21
21
  "configSchema": {
22
22
  "type": "object",
23
23
  "additionalProperties": false,
24
- "properties": {}
24
+ "properties": {
25
+ "auto_activity": {
26
+ "type": "boolean",
27
+ "description": "Automatically show the WeClawBot thinking pet during OpenClaw turns that mention the physical screen."
28
+ },
29
+ "auto_preview": {
30
+ "type": "boolean",
31
+ "description": "Automatically attach PNG previews after publishing a screen document from an OpenClaw session."
32
+ }
33
+ }
25
34
  }
26
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "WeClawBot pairing and screen-control CLI for local AI agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,10 +43,11 @@
43
43
  "./activity": "./lib/activity.mjs",
44
44
  "./direct-control": "./lib/direct-control.mjs",
45
45
  "./mqtt-control": "./lib/mqtt-control.mjs",
46
+ "./screen-preview": "./lib/screen-preview.mjs",
46
47
  "./package.json": "./package.json"
47
48
  },
48
49
  "scripts": {
49
- "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"
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
51
  },
51
52
  "dependencies": {
52
53
  "mqtt": "^5.10.4",
@@ -53,11 +53,13 @@ publish it with:
53
53
  weclawbotctl screen /path/to/screen-document.json
54
54
  ```
55
55
 
56
- or call `weclawbot_publish_screen_document` with the same document. Do not use
57
- OpenClaw Canvas for requests that mention WeClawBot, the physical screen, or
58
- “屏上”; Canvas is an OpenClaw UI surface, not the ESP32 e-paper display. Do
59
- not send raw text to firmware. The agent owns text layout, font choice, image
60
- rasterization, and page splitting; the device consumes pixels.
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.
61
63
 
62
64
  For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
63
65
  current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import { renderScreenDocumentPreviewPages, previewSummary } from "../lib/screen-preview.mjs";
4
+
5
+ const width = 16;
6
+ const height = 8;
7
+ const stride = 2;
8
+ const bytes = Buffer.alloc(stride * height, 0x00);
9
+ for (let i = 0; i < Math.min(width, height); i += 1) {
10
+ bytes[i * stride + (i >> 3)] |= 0x80 >> (i & 7);
11
+ }
12
+
13
+ const preview = renderScreenDocumentPreviewPages({
14
+ schema: "weclawbot.screen_document.v1",
15
+ pages: [{
16
+ format: "mono1",
17
+ width,
18
+ height,
19
+ stride,
20
+ data_b64: bytes.toString("base64"),
21
+ }],
22
+ }, { scale: 2 });
23
+
24
+ assert.equal(preview.length, 1);
25
+ assert.equal(preview[0].width, width * 2);
26
+ assert.equal(preview[0].height, height * 2);
27
+ assert.equal(preview[0].png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
28
+ assert.ok(preview[0].bytes > 50);
29
+ assert.match(preview[0].sha256, /^[0-9a-f]{64}$/u);
30
+
31
+ const summary = previewSummary(preview);
32
+ assert.equal(summary.available, true);
33
+ assert.equal(summary.pages[0].mime_type, "image/png");
34
+
35
+ console.log("screen-preview renderer: ok");
@@ -42,6 +42,11 @@ success only after it exits with success. If the user explicitly asks to
42
42
  replace whatever is currently shown, use `--force`; otherwise use the current
43
43
  screen revision in `base_revision`.
44
44
 
45
+ When running inside OpenClaw with the WeClawBot plugin tools available, prefer
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.
49
+
45
50
  The firmware receives pixels. Do not send raw text to firmware, and do not
46
51
  answer that direct delivery is unavailable before checking the local
47
52
  `weclawbotctl` profile.