@openbrt/weclawbotctl 0.1.14 → 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
@@ -57,12 +57,19 @@ 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;
63
69
  it does not lay out text, choose fonts, or split pages for agents.
64
70
 
65
71
  ```bash
72
+ weclawbotctl preview /path/to/screen-document.json
66
73
  weclawbotctl screen /path/to/screen-document.json
67
74
  ```
68
75
 
@@ -70,6 +77,14 @@ weclawbotctl screen /path/to/screen-document.json
70
77
  only after the firmware reports `applied`; a firmware `rejected` status or a
71
78
  timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
72
79
  publish acknowledgement is enough.
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.
73
88
 
74
89
  To clear the current note, use the firmware clear command:
75
90
 
@@ -125,7 +140,7 @@ openclaw plugins enable weclawbot
125
140
 
126
141
  Restart the OpenClaw gateway or app after installation so it reloads plugin
127
142
  tools. The doctor checks the OpenClaw version, plugin installation, plugin
128
- diagnostics, and local gateway reachability. If a local WSS gateway uses a
143
+ diagnostics, hook permission, and local gateway reachability. If a local WSS gateway uses a
129
144
  self-signed certificate, the doctor will suggest `NODE_EXTRA_CA_CERTS`. If the
130
145
  certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
131
146
  or use a certificate trusted by Node. The package does not rewrite other
@@ -199,6 +214,7 @@ pre-rendered screen document immediately:
199
214
 
200
215
  ```bash
201
216
  weclawbotctl doctor --online
217
+ weclawbotctl preview /path/to/screen-document.json
202
218
  weclawbotctl screen /path/to/screen-document.json
203
219
  ```
204
220
 
@@ -280,9 +296,11 @@ screen has exactly one page.
280
296
  Before publishing, agents should inspect or otherwise self-evaluate the rendered
281
297
  pages against the user's preferences and their own learned standards when their
282
298
  runtime supports it. Regenerate the document if the bitmap does not satisfy those
283
- 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.
284
301
 
285
302
  ```bash
303
+ weclawbotctl preview /path/to/screen-document.json
286
304
  weclawbotctl screen /path/to/screen-document.json
287
305
  ```
288
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
 
@@ -361,6 +406,13 @@ async function commandOpenClawInstall(values) {
361
406
  if (options.force) installArgs.push("--force");
362
407
  await runRequired(openclaw, installArgs);
363
408
  await runRequired(openclaw, ["plugins", "enable", "weclawbot"]);
409
+ await runRequired(openclaw, [
410
+ "config",
411
+ "set",
412
+ "plugins.entries.weclawbot.hooks.allowConversationAccess",
413
+ "true",
414
+ "--strict-json",
415
+ ]);
364
416
  console.log("OpenClaw plugin installed and enabled. Restart the OpenClaw gateway or app so it reloads plugins.");
365
417
  if (options.doctor) {
366
418
  await commandOpenClawDoctor(["--bin", openclaw, "--gateway=false"]);
@@ -402,6 +454,22 @@ async function commandOpenClawDoctor(values) {
402
454
  hint: weclawbotIssue ? "Upgrade and reinstall: weclawbotctl openclaw install" : "",
403
455
  });
404
456
 
457
+ const hooksAccess = await runCaptured(openclaw, [
458
+ "config",
459
+ "get",
460
+ "plugins.entries.weclawbot.hooks.allowConversationAccess",
461
+ "--json",
462
+ ], { timeoutMs });
463
+ const hooksEnabled = hooksAccess.code === 0 && /^\s*true\s*$/iu.test(hooksAccess.stdout);
464
+ checks.push({
465
+ name: "openclaw_weclawbot_hooks",
466
+ ok: hooksEnabled,
467
+ detail: hooksEnabled
468
+ ? "conversation hooks enabled for automatic thinking state"
469
+ : compactText(hooksAccess.stderr || hooksAccess.stdout || "hooks.allowConversationAccess is not enabled"),
470
+ hint: hooksEnabled ? "" : "Run: openclaw config set plugins.entries.weclawbot.hooks.allowConversationAccess true --strict-json",
471
+ });
472
+
405
473
  if (options.gateway) {
406
474
  const gatewayEnv = { ...process.env };
407
475
  const defaultCert = path.join(os.homedir(), ".openclaw", "gateway", "tls", "gateway-cert.pem");
@@ -716,7 +784,8 @@ function usage() {
716
784
  weclawbotctl unbind --yes
717
785
  weclawbotctl thinking [--ttl seconds] [--id correlation-id]
718
786
  weclawbotctl idle [--id correlation-id]
719
- 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]
720
789
  weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
721
790
  weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
722
791
  weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
package/index.mjs CHANGED
@@ -9,16 +9,24 @@ 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 { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "./lib/openclaw-preview.mjs";
13
+ import { renderScreenDocumentPreviewPages, writeScreenDocumentPreviewFiles } from "./lib/screen-preview.mjs";
12
14
 
13
15
  const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
16
+ const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
17
+ const activeRunActivities = new Map();
14
18
 
15
19
  // The long-running curator bridge remains a separate service. These tools keep
16
20
  // the local agent path explicit: validate first, then publish only with the
17
21
  // user's paired MQTT credential.
18
- export default defineToolPlugin({
22
+ const pluginEntry = defineToolPlugin({
19
23
  id: "weclawbot",
20
24
  name: "WeClawBot",
21
25
  description: "WeClawBot screen-curation skill pack and direct MQTT control tools.",
26
+ configSchema: Type.Object({
27
+ auto_activity: Type.Optional(Type.Boolean()),
28
+ auto_preview: Type.Optional(Type.Boolean()),
29
+ }, { additionalProperties: false }),
22
30
  tools: (tool) => [
23
31
  tool({
24
32
  name: "weclawbot_status",
@@ -107,8 +115,9 @@ export default defineToolPlugin({
107
115
  force_replace: Type.Optional(Type.Boolean()),
108
116
  wait_status: Type.Optional(Type.Boolean()),
109
117
  timeout_seconds: Type.Optional(Type.Number()),
118
+ preview_output_dir: Type.Optional(Type.String()),
110
119
  }, { additionalProperties: false }),
111
- 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 }) => {
112
121
  const outbound = cloneObject(document);
113
122
  if (force_replace) {
114
123
  outbound.force_replace = true;
@@ -127,6 +136,7 @@ export default defineToolPlugin({
127
136
  document: outbound,
128
137
  };
129
138
  const credentials = await requireCredentials(credentials_path);
139
+ const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: preview_output_dir || "" });
130
140
  if (wait_status !== false) {
131
141
  const delivery = await publishControlAndWaitStatus(credentials, control, {
132
142
  expectedDetail: outbound.id,
@@ -142,6 +152,7 @@ export default defineToolPlugin({
142
152
  force_replace: outbound.force_replace === true,
143
153
  warnings: validation.warnings,
144
154
  layout_guidance: validation.layout_guidance,
155
+ preview,
145
156
  status: delivery.status,
146
157
  };
147
158
  }
@@ -155,6 +166,7 @@ export default defineToolPlugin({
155
166
  force_replace: outbound.force_replace === true,
156
167
  warnings: validation.warnings,
157
168
  layout_guidance: validation.layout_guidance,
169
+ preview,
158
170
  };
159
171
  },
160
172
  }),
@@ -201,6 +213,257 @@ export default defineToolPlugin({
201
213
  ],
202
214
  });
203
215
 
216
+ const registerTools = pluginEntry.register;
217
+ pluginEntry.register = (api) => {
218
+ registerTools(api);
219
+ registerOpenClawHooks(api);
220
+ };
221
+
222
+ export default pluginEntry;
223
+
224
+ function registerOpenClawHooks(api) {
225
+ if (!api || typeof api.on !== "function") return;
226
+ api.on("before_agent_run", async (event, ctx) => {
227
+ await startHookActivity(api, event, ctx);
228
+ return { outcome: "pass" };
229
+ }, { timeoutMs: 5_000 });
230
+ api.on("before_agent_finalize", async (event, ctx) => {
231
+ await finishHookActivity(api, event, ctx);
232
+ return { action: "continue" };
233
+ }, { timeoutMs: 5_000 });
234
+ api.on("agent_end", async (event, ctx) => {
235
+ await finishHookActivity(api, event, ctx);
236
+ }, { timeoutMs: 5_000 });
237
+ api.on("after_tool_call", async (event, ctx) => {
238
+ await attachScreenPreview(api, event, ctx);
239
+ }, { timeoutMs: 10_000 });
240
+ }
241
+
242
+ async function startHookActivity(api, event, ctx) {
243
+ try {
244
+ if (api.pluginConfig?.auto_activity === false) return;
245
+ if (!shouldAutoActivity(event, ctx)) return;
246
+ const key = hookActivityKey(event, ctx);
247
+ if (!key || activeRunActivities.has(key)) return;
248
+ const correlationId = `openclaw-${sanitizeId(ctx?.runId || key).slice(0, 72)}`;
249
+ await publishControl(await requireCredentials(), {
250
+ schema: "weclawbot.control.v1",
251
+ id: `activity_${crypto.randomUUID()}`,
252
+ kind: "activity",
253
+ activity: {
254
+ schema: "weclawbot.activity.v1",
255
+ state: "thinking",
256
+ correlation_id: correlationId,
257
+ ttl_seconds: 120,
258
+ },
259
+ });
260
+ activeRunActivities.set(key, { correlationId, startedAt: Date.now() });
261
+ api.logger?.info?.(`weclawbot activity thinking sent for ${key}`);
262
+ } catch (error) {
263
+ api.logger?.debug?.(`weclawbot activity hook skipped: ${errorMessage(error)}`);
264
+ }
265
+ }
266
+
267
+ async function finishHookActivity(api, event, ctx) {
268
+ try {
269
+ const key = hookActivityKey(event, ctx);
270
+ if (!key) return;
271
+ const active = activeRunActivities.get(key);
272
+ if (!active) return;
273
+ activeRunActivities.delete(key);
274
+ await publishControl(await requireCredentials(), {
275
+ schema: "weclawbot.control.v1",
276
+ id: `activity_${crypto.randomUUID()}`,
277
+ kind: "activity",
278
+ activity: {
279
+ schema: "weclawbot.activity.v1",
280
+ state: "idle",
281
+ correlation_id: active.correlationId,
282
+ },
283
+ });
284
+ api.logger?.info?.(`weclawbot activity idle sent for ${key}`);
285
+ } catch (error) {
286
+ api.logger?.debug?.(`weclawbot activity idle hook skipped: ${errorMessage(error)}`);
287
+ }
288
+ }
289
+
290
+ async function attachScreenPreview(api, event, ctx) {
291
+ try {
292
+ if (api.pluginConfig?.auto_preview === false) return;
293
+ if (event?.error) return;
294
+ const sessionKey = resolveHookSessionKey(event, ctx);
295
+ if (!sessionKey) {
296
+ logPreviewSkip(api, event, "missing sessionKey");
297
+ return;
298
+ }
299
+ let document = null;
300
+ let source = "tool";
301
+ if (isScreenPublishTool(event?.toolName)) {
302
+ document = cloneObject(event.params?.document);
303
+ if (event.params?.force_replace) {
304
+ document.force_replace = true;
305
+ document.base_revision = "*";
306
+ }
307
+ } else if (isExecTool(event?.toolName)) {
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
+ }
312
+ if (!file) return;
313
+ document = JSON.parse(await fs.readFile(file, "utf8"));
314
+ source = "cli";
315
+ } else {
316
+ return;
317
+ }
318
+ await attachPreviewForDocument(api, document, { ...ctx, sessionKey }, source);
319
+ } catch (error) {
320
+ api.logger?.warn?.(`weclawbot preview attachment failed: ${errorMessage(error)}`);
321
+ }
322
+ }
323
+
324
+ async function attachPreviewForDocument(api, document, ctx, source) {
325
+ const validation = validateScreenDocument(document, {
326
+ agent_transport: { available: true, screen_document_available: true },
327
+ });
328
+ if (!validation.ok) {
329
+ api.logger?.warn?.(`weclawbot preview skipped: invalid screen document (${validation.errors?.[0] || "validation failed"})`);
330
+ return;
331
+ }
332
+ const previewPages = renderScreenDocumentPreviewPages(document);
333
+ if (previewPages.length === 0) {
334
+ api.logger?.warn?.("weclawbot preview skipped: no preview pages rendered");
335
+ return;
336
+ }
337
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
338
+ const files = [];
339
+ for (const page of previewPages) {
340
+ const file = path.join(dir, `${safeFilename(document.id || "screen")}-p${page.index + 1}.png`);
341
+ await fs.writeFile(file, page.png);
342
+ files.push({ path: file });
343
+ }
344
+ const pageLabel = `${previewPages.length} page${previewPages.length === 1 ? "" : "s"}`;
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
+ }
378
+ scheduleRemove(dir);
379
+ }
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
+
454
+ function shouldAutoActivity(event, ctx) {
455
+ if (String(ctx?.trigger || "").includes("curator")) return false;
456
+ const prompt = String(event?.prompt || "");
457
+ if (prompt.includes("WECLAWBOT_CURATOR_EVENT")) return false;
458
+ return SCREEN_PROMPT_PATTERN.test(prompt);
459
+ }
460
+
461
+ function hookActivityKey(event, ctx) {
462
+ if (ctx?.runId || event?.runId) return `run:${ctx?.runId || event?.runId}`;
463
+ if (ctx?.sessionKey) return `session:${ctx.sessionKey}`;
464
+ return "";
465
+ }
466
+
204
467
  async function requireCredentials(credentialsPath) {
205
468
  const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
206
469
  const payload = await readCredentials(file);
@@ -239,6 +502,25 @@ function clearStatusDetail(target) {
239
502
  return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
240
503
  }
241
504
 
505
+ function sanitizeId(value) {
506
+ return String(value || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/gu, "_");
507
+ }
508
+
509
+ function safeFilename(value) {
510
+ return sanitizeId(value).slice(0, 80) || "screen";
511
+ }
512
+
513
+ function scheduleRemove(dir) {
514
+ const timer = setTimeout(() => {
515
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
516
+ }, 60_000);
517
+ timer.unref?.();
518
+ }
519
+
520
+ function errorMessage(error) {
521
+ return String(error instanceof Error ? error.message : error).replace(/\s+/gu, " ").trim().slice(0, 240);
522
+ }
523
+
242
524
  function maskedMqtt(config, topics) {
243
525
  return {
244
526
  url: config.url,
@@ -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
+ }
@@ -0,0 +1,166 @@
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";
5
+ import { deflateSync } from "node:zlib";
6
+
7
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
8
+
9
+ export function renderScreenDocumentPreviewPages(document, options = {}) {
10
+ const pages = Array.isArray(document?.pages) ? document.pages : [];
11
+ const scale = clampInteger(options.scale ?? 2, 1, 4);
12
+ return pages.map((page, index) => {
13
+ const png = renderMonoPagePreview(page, { scale });
14
+ return {
15
+ index,
16
+ width: Number(page.width) * scale,
17
+ height: Number(page.height) * scale,
18
+ scale,
19
+ png,
20
+ bytes: png.length,
21
+ sha256: crypto.createHash("sha256").update(png).digest("hex"),
22
+ };
23
+ });
24
+ }
25
+
26
+ export function previewSummary(previewPages) {
27
+ return {
28
+ available: previewPages.length > 0,
29
+ pages: previewPages.map((page) => ({
30
+ index: page.index,
31
+ width: page.width,
32
+ height: page.height,
33
+ scale: page.scale,
34
+ bytes: page.bytes,
35
+ sha256: page.sha256,
36
+ mime_type: "image/png",
37
+ })),
38
+ };
39
+ }
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
+
70
+ function renderMonoPagePreview(page, options = {}) {
71
+ const width = Number(page?.width);
72
+ const height = Number(page?.height);
73
+ const stride = Number(page?.stride);
74
+ const scale = clampInteger(options.scale ?? 2, 1, 4);
75
+ if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
76
+ throw new Error("preview page width/height must be positive integers");
77
+ }
78
+ if (!Number.isInteger(stride) || stride < Math.ceil(width / 8)) {
79
+ throw new Error("preview page stride is invalid");
80
+ }
81
+ const packed = Buffer.from(String(page?.data_b64 || ""), "base64");
82
+ if (packed.length < stride * height) {
83
+ throw new Error("preview page data is shorter than stride * height");
84
+ }
85
+
86
+ const outWidth = width * scale;
87
+ const outHeight = height * scale;
88
+ const gray = Buffer.alloc(outWidth * outHeight, 0xff);
89
+ for (let y = 0; y < height; y += 1) {
90
+ const row = y * stride;
91
+ for (let x = 0; x < width; x += 1) {
92
+ const black = (packed[row + (x >> 3)] & (0x80 >> (x & 7))) !== 0;
93
+ if (!black) continue;
94
+ for (let dy = 0; dy < scale; dy += 1) {
95
+ const outRow = (y * scale + dy) * outWidth;
96
+ for (let dx = 0; dx < scale; dx += 1) {
97
+ gray[outRow + x * scale + dx] = 0;
98
+ }
99
+ }
100
+ }
101
+ }
102
+ return encodeGrayscalePng(gray, outWidth, outHeight);
103
+ }
104
+
105
+ function encodeGrayscalePng(gray, width, height) {
106
+ const raw = Buffer.alloc((width + 1) * height);
107
+ for (let y = 0; y < height; y += 1) {
108
+ const row = y * (width + 1);
109
+ raw[row] = 0;
110
+ gray.copy(raw, row + 1, y * width, (y + 1) * width);
111
+ }
112
+ const ihdr = Buffer.alloc(13);
113
+ ihdr.writeUInt32BE(width, 0);
114
+ ihdr.writeUInt32BE(height, 4);
115
+ ihdr[8] = 8;
116
+ ihdr[9] = 0;
117
+ ihdr[10] = 0;
118
+ ihdr[11] = 0;
119
+ ihdr[12] = 0;
120
+ return Buffer.concat([
121
+ PNG_SIGNATURE,
122
+ pngChunk("IHDR", ihdr),
123
+ pngChunk("IDAT", deflateSync(raw)),
124
+ pngChunk("IEND", Buffer.alloc(0)),
125
+ ]);
126
+ }
127
+
128
+ function pngChunk(type, data) {
129
+ const typeBuf = Buffer.from(type, "ascii");
130
+ const len = Buffer.alloc(4);
131
+ len.writeUInt32BE(data.length, 0);
132
+ const crc = Buffer.alloc(4);
133
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
134
+ return Buffer.concat([len, typeBuf, data, crc]);
135
+ }
136
+
137
+ const crcTable = new Uint32Array(256).map((_, index) => {
138
+ let c = index;
139
+ for (let bit = 0; bit < 8; bit += 1) {
140
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
141
+ }
142
+ return c >>> 0;
143
+ });
144
+
145
+ function crc32(buffer) {
146
+ let crc = 0xffffffff;
147
+ for (const byte of buffer) {
148
+ crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
149
+ }
150
+ return (crc ^ 0xffffffff) >>> 0;
151
+ }
152
+
153
+ function clampInteger(value, min, max) {
154
+ const parsed = Number.parseInt(String(value), 10);
155
+ if (!Number.isFinite(parsed)) return min;
156
+ return Math.max(min, Math.min(max, parsed));
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
+ }
@@ -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": "Best-effort fallback that attaches PNG previews after publishing. Agents should still send preview.pages[].path to users explicitly."
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.16",
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 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"
50
51
  },
51
52
  "dependencies": {
52
53
  "mqtt": "^5.10.4",
@@ -53,11 +53,18 @@ 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`: 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.
61
68
 
62
69
  For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
63
70
  current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
@@ -84,8 +91,10 @@ Before publishing, review the actual rendered bitmap pages when your runtime can
84
91
  inspect images. Judge the preview against the user's preferences and the agent's
85
92
  own learned standards: legibility, margins, crowding, page count, and continuity
86
93
  across pages. If the preview does not satisfy those standards, regenerate the
87
- pages before publishing. This review loop belongs in the agent/tool layer; do not
88
- 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.
89
98
 
90
99
  `weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
91
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");
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+
4
+ import { renderScreenDocumentPreviewPages, previewSummary, writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
5
+
6
+ const width = 16;
7
+ const height = 8;
8
+ const stride = 2;
9
+ const bytes = Buffer.alloc(stride * height, 0x00);
10
+ for (let i = 0; i < Math.min(width, height); i += 1) {
11
+ bytes[i * stride + (i >> 3)] |= 0x80 >> (i & 7);
12
+ }
13
+
14
+ const document = {
15
+ schema: "weclawbot.screen_document.v1",
16
+ pages: [{
17
+ format: "mono1",
18
+ width,
19
+ height,
20
+ stride,
21
+ data_b64: bytes.toString("base64"),
22
+ }],
23
+ };
24
+
25
+ const preview = renderScreenDocumentPreviewPages(document, { scale: 2 });
26
+
27
+ assert.equal(preview.length, 1);
28
+ assert.equal(preview[0].width, width * 2);
29
+ assert.equal(preview[0].height, height * 2);
30
+ assert.equal(preview[0].png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
31
+ assert.ok(preview[0].bytes > 50);
32
+ assert.match(preview[0].sha256, /^[0-9a-f]{64}$/u);
33
+
34
+ const summary = previewSummary(preview);
35
+ assert.equal(summary.available, true);
36
+ assert.equal(summary.pages[0].mime_type, "image/png");
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
+
46
+ console.log("screen-preview renderer: ok");
@@ -42,6 +42,14 @@ 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 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`.
52
+
45
53
  The firmware receives pixels. Do not send raw text to firmware, and do not
46
54
  answer that direct delivery is unavailable before checking the local
47
55
  `weclawbotctl` profile.