@openbrt/weclawbotctl 0.1.9 → 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
@@ -52,6 +52,18 @@ weclawbotctl thinking --id "$task_id" --ttl 45
52
52
  weclawbotctl idle --id "$task_id"
53
53
  ```
54
54
 
55
+ `idle` must use the same id as the active `thinking` message. Newer firmware
56
+ rejects stale or unrelated `idle` messages and keeps the visible thinking state.
57
+ The bundled OpenClaw bridge publishes `thinking` before every curator job and
58
+ `idle` after it finishes, so WeChat-origin official-mode work also gets a visible
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.
66
+
55
67
  To put text, status, diagrams, or images on the screen, render them into a
56
68
  pre-rendered `weclawbot.screen_document.v1` first. The firmware receives pixels;
57
69
  it does not lay out text, choose fonts, or split pages for agents.
@@ -64,10 +76,23 @@ weclawbotctl screen /path/to/screen-document.json
64
76
  only after the firmware reports `applied`; a firmware `rejected` status or a
65
77
  timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
66
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.
82
+
83
+ To clear the current note, use the firmware clear command:
84
+
85
+ ```bash
86
+ weclawbotctl clear
87
+ ```
88
+
89
+ Do not emulate clear by publishing a blank, white, or black screen document.
90
+ That creates a new note and can leave the physical screen looking black.
67
91
 
68
92
  The package also includes an OpenClaw integration: the `weclawbot-curator`
69
93
  skill, `weclawbot_status`, `weclawbot_validate_screen_document`,
70
- `weclawbot_publish_screen_document`, `weclawbot_validate_activity`,
94
+ `weclawbot_clear_screen`, `weclawbot_publish_screen_document`,
95
+ `weclawbot_validate_activity`,
71
96
  `weclawbot_publish_activity`, and a small outbound bridge service. The bridge
72
97
  polls `weclawbot.link`; no public HTTP endpoint, port forwarding, or WeChat
73
98
  credential is required on the OpenClaw host.
@@ -109,7 +134,7 @@ openclaw plugins enable weclawbot
109
134
 
110
135
  Restart the OpenClaw gateway or app after installation so it reloads plugin
111
136
  tools. The doctor checks the OpenClaw version, plugin installation, plugin
112
- 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
113
138
  self-signed certificate, the doctor will suggest `NODE_EXTRA_CA_CERTS`. If the
114
139
  certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
115
140
  or use a certificate trusted by Node. The package does not rewrite other
@@ -248,6 +273,24 @@ three `mono1` pages, and a future UTC expiry. Agents may use PIL, Canvas, SVG
248
273
  rasterization, screenshots, or any local renderer, but the MQTT payload must be
249
274
  pixels:
250
275
 
276
+ There is no canonical WeClawBot renderer that agents must use. The stable
277
+ contract is the bounded pixel document plus device feedback. Keep layout,
278
+ typography, and page-composition decisions in the agent/tool layer so skills and
279
+ models can improve the result without requiring users to flash firmware.
280
+ Preserve any layout preferences, visual language, page rhythm, font choices, or
281
+ review habits that the user and agent have already developed; package upgrades
282
+ should add capabilities without resetting that accumulated practice.
283
+
284
+ The hardware facts are stable: the content viewport is 368 x 206 mono1 pixels,
285
+ content documents may contain one to three pages, and the firmware will not split
286
+ a single pixel page after receiving it. If `pages.length === 1`, the physical
287
+ screen has exactly one page.
288
+
289
+ Before publishing, agents should inspect or otherwise self-evaluate the rendered
290
+ pages against the user's preferences and their own learned standards when their
291
+ runtime supports it. Regenerate the document if the bitmap does not satisfy those
292
+ standards.
293
+
251
294
  ```bash
252
295
  weclawbotctl screen /path/to/screen-document.json
253
296
  ```
@@ -2,6 +2,7 @@
2
2
 
3
3
  import process from "node:process";
4
4
  import { spawn } from "node:child_process";
5
+ import path from "node:path";
5
6
 
6
7
  const ACTIONS = new Set([
7
8
  "ignore",
@@ -53,6 +54,10 @@ function stop() {
53
54
 
54
55
  async function handleJob(job) {
55
56
  const started = Date.now();
57
+ const activityId = `openclaw-${String(job.id || cryptoRandom()).replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 64)}`;
58
+ await publishBridgeActivity("thinking", activityId, {
59
+ ttlSeconds: Math.min(120, Math.max(5, config.agentTimeoutSeconds + 15)),
60
+ });
56
61
  try {
57
62
  const decision = await curateWithOpenClaw(job);
58
63
  await gatewayJson("POST", `${config.jobsPath}/${encodeURIComponent(job.id)}/result`, {
@@ -71,6 +76,24 @@ async function handleJob(job) {
71
76
  } catch (resultError) {
72
77
  log("failure_report_failed", { job: job.id, error: errorMessage(resultError) });
73
78
  }
79
+ } finally {
80
+ await publishBridgeActivity("idle", activityId);
81
+ }
82
+ }
83
+
84
+ async function publishBridgeActivity(state, id, options = {}) {
85
+ if (!config.screenActivity) return;
86
+ const args = [state, "--id", id];
87
+ if (state === "thinking") args.push("--ttl", String(options.ttlSeconds || 45));
88
+ try {
89
+ const result = await run(config.weclawbotctlBin, args, 8000);
90
+ if (result.code !== 0) {
91
+ log("activity_failed", { state, id, error: shortText(result.stderr || result.stdout || `exit ${result.code}`) });
92
+ } else {
93
+ log("activity_sent", { state, id });
94
+ }
95
+ } catch (error) {
96
+ log("activity_failed", { state, id, error: errorMessage(error) });
74
97
  }
75
98
  }
76
99
 
@@ -316,8 +339,10 @@ function loadConfig(env) {
316
339
  jobsPath: String(env.WEC_GATEWAY_JOBS_PATH || "/api/agent/curator/jobs"),
317
340
  agentId: String(env.WEC_OPENCLAW_AGENT || "weclawbot"),
318
341
  openclawBin: String(env.WEC_OPENCLAW_BIN || "openclaw"),
342
+ weclawbotctlBin: String(env.WEC_WECLAWBOTCTL_BIN || siblingBin("weclawbotctl")),
319
343
  transport: env.WEC_OPENCLAW_TRANSPORT === "local" ? "local" : "gateway",
320
344
  thinking: String(env.WEC_OPENCLAW_THINKING || "low"),
345
+ screenActivity: env.WEC_SCREEN_ACTIVITY !== "0" && env.WEC_SCREEN_ACTIVITY !== "false",
321
346
  pollWaitMs: positiveInteger(env.WEC_POLL_WAIT_MS, 20000),
322
347
  retryMs: positiveInteger(env.WEC_RETRY_MS, 3000),
323
348
  agentTimeoutSeconds: positiveInteger(env.WEC_AGENT_TIMEOUT_SECONDS, 20),
@@ -326,6 +351,15 @@ function loadConfig(env) {
326
351
  };
327
352
  }
328
353
 
354
+ function siblingBin(name) {
355
+ const script = process.argv[1] || "";
356
+ return script ? path.join(path.dirname(script), name) : name;
357
+ }
358
+
359
+ function cryptoRandom() {
360
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
361
+ }
362
+
329
363
  function positiveInteger(value, fallback) {
330
364
  const parsed = Number.parseInt(String(value || ""), 10);
331
365
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -17,7 +17,7 @@ const DEFAULT_OPENCLAW_PLUGIN_SPEC = "@openbrt/weclawbotctl";
17
17
  const MIN_OPENCLAW_VERSION = "2026.6.9";
18
18
 
19
19
  const [command, ...args] = process.argv.slice(2);
20
- const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "openclaw"]);
20
+ const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "clear", "openclaw"]);
21
21
  if (!commands.has(command)) {
22
22
  usage();
23
23
  process.exit(64);
@@ -30,6 +30,7 @@ try {
30
30
  else if (command === "export") await commandExport(args);
31
31
  else if (command === "unbind") await commandUnbind(args);
32
32
  else if (command === "screen") await commandScreen(args);
33
+ else if (command === "clear") await commandClear(args);
33
34
  else if (command === "openclaw") await commandOpenClaw(args);
34
35
  else await commandActivity(command, args);
35
36
  } catch (error) {
@@ -245,6 +246,8 @@ async function commandScreen(values) {
245
246
  id: document.id,
246
247
  pages: document.pages.length,
247
248
  force_replace: document.force_replace === true,
249
+ warnings: validation.warnings,
250
+ layout_guidance: validation.layout_guidance,
248
251
  status: delivery.status,
249
252
  }));
250
253
  return;
@@ -257,9 +260,50 @@ async function commandScreen(values) {
257
260
  id: document.id,
258
261
  pages: document.pages.length,
259
262
  force_replace: document.force_replace === true,
263
+ warnings: validation.warnings,
264
+ layout_guidance: validation.layout_guidance,
260
265
  }));
261
266
  }
262
267
 
268
+ async function commandClear(values) {
269
+ const options = parseOptions(values, {
270
+ credentials: credentialsPath(),
271
+ target: "note",
272
+ wait: true,
273
+ timeout: 12,
274
+ });
275
+ if (options._.length > 1) {
276
+ throw new Error("Usage: weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]");
277
+ }
278
+ const target = normalizeClearTarget(options._[0] || options.target);
279
+ const control = {
280
+ schema: "weclawbot.control.v1",
281
+ id: `clear_${crypto.randomUUID()}`,
282
+ kind: "screen_clear",
283
+ target,
284
+ };
285
+ const credentials = await requireCredentials(expandPath(options.credentials));
286
+ if (options.wait) {
287
+ const delivery = await publishControlAndWaitStatus(credentials, control, {
288
+ expectedDetail: clearStatusDetail(target),
289
+ timeoutMs: Math.max(1, Number(options.timeout) || 12) * 1000,
290
+ });
291
+ if (delivery.status.kind !== "applied") {
292
+ throw new Error(`Device rejected screen clear: ${delivery.status.detail || "unknown"}`);
293
+ }
294
+ console.log(JSON.stringify({
295
+ ok: true,
296
+ published: true,
297
+ applied: true,
298
+ target,
299
+ status: delivery.status,
300
+ }));
301
+ return;
302
+ }
303
+ await publishControl(credentials, control);
304
+ console.log(JSON.stringify({ ok: true, published: true, applied: null, target }));
305
+ }
306
+
263
307
  async function commandActivity(state, values) {
264
308
  const options = parseOptions(values, {
265
309
  credentials: credentialsPath(),
@@ -317,6 +361,13 @@ async function commandOpenClawInstall(values) {
317
361
  if (options.force) installArgs.push("--force");
318
362
  await runRequired(openclaw, installArgs);
319
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
+ ]);
320
371
  console.log("OpenClaw plugin installed and enabled. Restart the OpenClaw gateway or app so it reloads plugins.");
321
372
  if (options.doctor) {
322
373
  await commandOpenClawDoctor(["--bin", openclaw, "--gateway=false"]);
@@ -358,6 +409,22 @@ async function commandOpenClawDoctor(values) {
358
409
  hint: weclawbotIssue ? "Upgrade and reinstall: weclawbotctl openclaw install" : "",
359
410
  });
360
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
+
361
428
  if (options.gateway) {
362
429
  const gatewayEnv = { ...process.env };
363
430
  const defaultCert = path.join(os.homedir(), ".openclaw", "gateway", "tls", "gateway-cert.pem");
@@ -643,6 +710,18 @@ function compactText(value) {
643
710
  return String(value || "").replace(/\s+/gu, " ").trim().slice(0, 500);
644
711
  }
645
712
 
713
+ function normalizeClearTarget(value) {
714
+ const target = String(value || "note").trim();
715
+ if (target === "note" || target === "idle_photo" || target === "photo") {
716
+ return target === "photo" ? "idle_photo" : target;
717
+ }
718
+ throw new Error("clear target must be note or idle_photo");
719
+ }
720
+
721
+ function clearStatusDetail(target) {
722
+ return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
723
+ }
724
+
646
725
  function shellValue(value) {
647
726
  return `'${String(value).replaceAll("'", "'\\''")}'`;
648
727
  }
@@ -661,6 +740,7 @@ function usage() {
661
740
  weclawbotctl thinking [--ttl seconds] [--id correlation-id]
662
741
  weclawbotctl idle [--id correlation-id]
663
742
  weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]
743
+ weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
664
744
  weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
665
745
  weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
666
746
  }
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",
@@ -59,6 +66,43 @@ export default defineToolPlugin({
59
66
  }, { additionalProperties: false }),
60
67
  execute: ({ document, device_context }) => validateScreenDocument(document, device_context),
61
68
  }),
69
+ tool({
70
+ name: "weclawbot_clear_screen",
71
+ label: "Clear WeClawBot screen",
72
+ description: "Clear the paired WeClawBot note or idle-photo state with the firmware screen_clear control. Do not emulate clearing by publishing a blank or black bitmap.",
73
+ parameters: Type.Object({
74
+ target: Type.Optional(Type.String()),
75
+ credentials_path: Type.Optional(Type.String()),
76
+ wait_status: Type.Optional(Type.Boolean()),
77
+ timeout_seconds: Type.Optional(Type.Number()),
78
+ }, { additionalProperties: false }),
79
+ execute: async ({ target, credentials_path, wait_status, timeout_seconds }) => {
80
+ const clearTarget = normalizeClearTarget(target);
81
+ const control = {
82
+ schema: "weclawbot.control.v1",
83
+ id: `clear_${crypto.randomUUID()}`,
84
+ kind: "screen_clear",
85
+ target: clearTarget,
86
+ };
87
+ const credentials = await requireCredentials(credentials_path);
88
+ if (wait_status !== false) {
89
+ const delivery = await publishControlAndWaitStatus(credentials, control, {
90
+ expectedDetail: clearStatusDetail(clearTarget),
91
+ timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
92
+ });
93
+ return {
94
+ ok: delivery.status.kind === "applied",
95
+ published: true,
96
+ applied: delivery.status.kind === "applied",
97
+ rejected: delivery.status.kind === "rejected",
98
+ target: clearTarget,
99
+ status: delivery.status,
100
+ };
101
+ }
102
+ await publishControl(credentials, control);
103
+ return { ok: true, published: true, applied: null, target: clearTarget };
104
+ },
105
+ }),
62
106
  tool({
63
107
  name: "weclawbot_publish_screen_document",
64
108
  label: "Publish WeClawBot screen document",
@@ -90,6 +134,8 @@ export default defineToolPlugin({
90
134
  document: outbound,
91
135
  };
92
136
  const credentials = await requireCredentials(credentials_path);
137
+ const previewPages = renderScreenDocumentPreviewPages(outbound);
138
+ const preview = previewSummary(previewPages);
93
139
  if (wait_status !== false) {
94
140
  const delivery = await publishControlAndWaitStatus(credentials, control, {
95
141
  expectedDetail: outbound.id,
@@ -103,6 +149,9 @@ export default defineToolPlugin({
103
149
  id: outbound.id,
104
150
  pages: outbound.pages.length,
105
151
  force_replace: outbound.force_replace === true,
152
+ warnings: validation.warnings,
153
+ layout_guidance: validation.layout_guidance,
154
+ preview,
106
155
  status: delivery.status,
107
156
  };
108
157
  }
@@ -114,6 +163,9 @@ export default defineToolPlugin({
114
163
  id: outbound.id,
115
164
  pages: outbound.pages.length,
116
165
  force_replace: outbound.force_replace === true,
166
+ warnings: validation.warnings,
167
+ layout_guidance: validation.layout_guidance,
168
+ preview,
117
169
  };
118
170
  },
119
171
  }),
@@ -160,6 +212,218 @@ export default defineToolPlugin({
160
212
  ],
161
213
  });
162
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
+
163
427
  async function requireCredentials(credentialsPath) {
164
428
  const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
165
429
  const payload = await readCredentials(file);
@@ -181,11 +445,47 @@ function expandPath(value) {
181
445
  return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
182
446
  }
183
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
+
184
453
  function cloneObject(value) {
185
454
  if (!value || typeof value !== "object") throw new Error("document must be an object");
186
455
  return JSON.parse(JSON.stringify(value));
187
456
  }
188
457
 
458
+ function normalizeClearTarget(value) {
459
+ const target = String(value || "note").trim();
460
+ if (target === "note" || target === "idle_photo" || target === "photo") {
461
+ return target === "photo" ? "idle_photo" : target;
462
+ }
463
+ throw new Error("clear target must be note or idle_photo");
464
+ }
465
+
466
+ function clearStatusDetail(target) {
467
+ return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
468
+ }
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
+
189
489
  function maskedMqtt(config, topics) {
190
490
  return {
191
491
  url: config.url,
@@ -29,6 +29,7 @@ export function resolveDeviceContext(value) {
29
29
  export function validateScreenDocument(value, suppliedContext) {
30
30
  const context = resolveDeviceContext(suppliedContext);
31
31
  const errors = [];
32
+ const pageStats = [];
32
33
  const document = value && typeof value === "object" ? value : null;
33
34
  const viewport = context.content_viewport;
34
35
  if (!document) return result(context, errors.concat("document must be an object"));
@@ -48,12 +49,15 @@ export function validateScreenDocument(value, suppliedContext) {
48
49
  if (!Array.isArray(document.pages) || document.pages.length < 1 || document.pages.length > viewport.max_pages) {
49
50
  errors.push(`pages must contain 1..${viewport.max_pages} items`);
50
51
  } else {
51
- document.pages.forEach((page, index) => validatePage(page, index, viewport, errors));
52
+ document.pages.forEach((page, index) => validatePage(page, index, viewport, errors, pageStats));
53
+ if (pageStats.length > 0 && pageStats.every((stat) => stat.uniform)) {
54
+ errors.push("uniform_screen_document: content pages cannot all be a single color; use screen_clear for clearing");
55
+ }
52
56
  }
53
- return result(context, errors);
57
+ return result(context, errors, document);
54
58
  }
55
59
 
56
- function validatePage(page, index, viewport, errors) {
60
+ function validatePage(page, index, viewport, errors, pageStats) {
57
61
  if (!page || typeof page !== "object") {
58
62
  errors.push(`pages[${index}] must be an object`);
59
63
  return;
@@ -77,14 +81,35 @@ function validatePage(page, index, viewport, errors) {
77
81
  errors.push(`pages[${index}].data_b64 is not valid base64`);
78
82
  } else if (Number.isInteger(stride) && Number.isInteger(height) && bytes.length !== stride * height) {
79
83
  errors.push(`pages[${index}].data_b64 byte length does not match stride * height`);
84
+ } else if (bytes.length > 0) {
85
+ pageStats.push({ index, uniform: bytes.every((byte) => byte === bytes[0]) });
80
86
  }
81
87
  }
82
88
 
83
- function result(context, errors) {
89
+ function result(context, errors, document = null) {
90
+ const pageCount = Array.isArray(document?.pages) ? document.pages.length : 0;
91
+ const warnings = [];
92
+ if (pageCount === 1) {
93
+ warnings.push("single_page_document: firmware will not split pixels; verify the page remains readable before publishing");
94
+ } else if (pageCount > 1) {
95
+ warnings.push("multi_page_document: firmware will auto-flip pages and manual left/right buttons can change pages");
96
+ }
84
97
  return {
85
98
  ok: errors.length === 0,
86
99
  errors,
100
+ warnings,
87
101
  viewport: context.content_viewport,
102
+ layout_guidance: {
103
+ hardware_contract: "agent supplies pre-rendered mono1 pixels; firmware validates geometry and does not lay out text or split pages",
104
+ page_contract: "pages.length is the physical page count; content documents support 1-3 pages",
105
+ preference_policy: "preserve user-agent learned layout preferences unless they violate hardware bounds or the user asks to change them",
106
+ review_policy: "agent should inspect rendered bitmap pages against user preferences and learned standards before publishing when possible",
107
+ max_pages: context.content_viewport?.max_pages,
108
+ content_viewport_px: {
109
+ width: context.content_viewport?.width,
110
+ height: context.content_viewport?.height,
111
+ },
112
+ },
88
113
  agent_transport: context.agent_transport || null,
89
114
  direct_delivery_ready: context.agent_transport?.screen_document_available === true
90
115
  || context.agent_transport?.available === true,
@@ -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
+ }
@@ -6,6 +6,7 @@
6
6
  "tools": [
7
7
  "weclawbot_status",
8
8
  "weclawbot_validate_screen_document",
9
+ "weclawbot_clear_screen",
9
10
  "weclawbot_publish_screen_document",
10
11
  "weclawbot_validate_activity",
11
12
  "weclawbot_publish_activity"
@@ -20,6 +21,15 @@
20
21
  "configSchema": {
21
22
  "type": "object",
22
23
  "additionalProperties": false,
23
- "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
+ }
24
34
  }
25
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.9",
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,41 @@ 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.
63
+
64
+ For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
65
+ current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
66
+ simulate clearing by publishing a blank, white, or black `screen_document`; that
67
+ creates a new note instead of clearing firmware state.
68
+
69
+ ## Layout, preferences, and page splitting
70
+
71
+ Treat this skill as a hardware contract and starting point, not as a fixed house
72
+ style. If the user and agent have already developed layout preferences, visual
73
+ language, page rhythm, font choices, or review habits, preserve those choices
74
+ unless the user asks to change them or they violate the device bounds below.
75
+ Skill upgrades must be additive and compatible with accumulated user-agent
76
+ practice; do not reset or overwrite local style memory just because this package
77
+ changed.
78
+
79
+ Hardware facts: the content viewport is 368 x 206 mono1 pixels, and a document
80
+ may contain one to three content pages. The firmware will not split a single
81
+ pixel page after receiving it; if the document has `pages.length === 1`, the
82
+ physical screen has exactly one page. Multi-page documents can be auto-flipped by
83
+ firmware and changed with the physical left/right buttons.
84
+
85
+ Before publishing, review the actual rendered bitmap pages when your runtime can
86
+ inspect images. Judge the preview against the user's preferences and the agent's
87
+ own learned standards: legibility, margins, crowding, page count, and continuity
88
+ 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.
61
91
 
62
92
  `weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
63
93
  device status topic by default. Treat only `applied` as success. If the device
@@ -92,9 +122,11 @@ For a paired device, publish `weclawbot.activity.v1` with `state: "thinking"`
92
122
  immediately before an LLM call, long retrieval, or multi-step operation, then
93
123
  always publish `state: "idle"` in a `finally` path after success or failure.
94
124
  The thinking message requires a 5-120 second `ttl_seconds` and a stable
95
- `correlation_id`; use `weclawbot_validate_activity` first. It is a temporary
96
- overlay that restores the exact prior page, not a screen document and not a
97
- status to leave on indefinitely. Do not publish it when
125
+ `correlation_id`; the matching idle message must reuse the same id. Newer
126
+ firmware rejects stale or unrelated idle messages and keeps the active thinking
127
+ state. Use `weclawbot_validate_activity` first. It is a temporary overlay that
128
+ restores the exact prior page, not a screen document and not a status to leave on
129
+ indefinitely. Do not publish it when
98
130
  `agent_transport.available` is false.
99
131
 
100
132
  Return this shape:
@@ -7,6 +7,7 @@ Wants=network-online.target
7
7
  Type=simple
8
8
  EnvironmentFile=%h/.config/weclawbot/openclaw-curator.env
9
9
  Environment=NODE_EXTRA_CA_CERTS=%h/.openclaw/gateway/tls/gateway-cert.pem
10
+ Environment=WEC_WECLAWBOTCTL_BIN=%h/.npm-global/bin/weclawbotctl
10
11
  ExecStart=%h/.npm-global/bin/weclawbot-openclaw-bridge
11
12
  Restart=always
12
13
  RestartSec=3
@@ -5,6 +5,7 @@ import { resolveDeviceContext, validateScreenDocument } from "../lib/direct-cont
5
5
  const context = resolveDeviceContext();
6
6
  const viewport = context.content_viewport;
7
7
  const bytes = Buffer.alloc(Math.ceil(viewport.width / 8) * viewport.height, 0xff);
8
+ bytes[0] = 0x00;
8
9
  const valid = validateScreenDocument({
9
10
  schema: "weclawbot.screen_document.v1",
10
11
  id: "test-card",
@@ -33,4 +34,22 @@ const invalid = validateScreenDocument({
33
34
  assert.equal(invalid.ok, false);
34
35
  assert.ok(invalid.errors.some((error) => error.includes("width")));
35
36
 
37
+ const uniform = validateScreenDocument({
38
+ schema: "weclawbot.screen_document.v1",
39
+ id: "bad-clear",
40
+ base_revision: "",
41
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
42
+ target: "content",
43
+ kind: "replace",
44
+ pages: [{
45
+ format: "mono1",
46
+ width: viewport.width,
47
+ height: viewport.height,
48
+ stride: Math.ceil(viewport.width / 8),
49
+ data_b64: Buffer.alloc(Math.ceil(viewport.width / 8) * viewport.height, 0x00).toString("base64"),
50
+ }],
51
+ }, context);
52
+ assert.equal(uniform.ok, false);
53
+ assert.ok(uniform.errors.some((error) => error.includes("uniform_screen_document")));
54
+
36
55
  console.log("direct-control validator: ok");
@@ -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,17 @@ 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.
53
+
54
+ The content viewport is 368 x 206 mono1 pixels, with one to three content pages.
55
+ The firmware will not split a single pixel page after receiving it; `pages.length`
56
+ is the page count on the physical screen. Preserve user-agent layout preferences,
57
+ visual language, and review habits across plugin upgrades unless they violate
58
+ the hardware limits or the user asks to change them.