@openbrt/weclawbotctl 0.1.17 → 0.1.19

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
@@ -86,6 +86,13 @@ value contains the same `preview.pages[].path` list. The agent owns delivering
86
86
  that preview to the user. The optional automatic preview hook is off by default
87
87
  and exists only for legacy experiments.
88
88
 
89
+ The purpose of sending the preview is not just proof that MQTT worked. The
90
+ preview is the feedback surface that lets the user's agent learn the user's
91
+ reading habits, layout taste, density tolerance, font preferences, and page
92
+ rhythm over time. Treat "send to screen" as a two-part delivery: the device is
93
+ updated, and the user receives the same effect image in the current chat unless
94
+ the agent reports a concrete preview-delivery failure.
95
+
89
96
  To clear the current note, use the firmware clear command:
90
97
 
91
98
  ```bash
@@ -297,7 +304,9 @@ Before publishing, agents should inspect or otherwise self-evaluate the rendered
297
304
  pages against the user's preferences and their own learned standards when their
298
305
  runtime supports it. Regenerate the document if the bitmap does not satisfy those
299
306
  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.
307
+ the current chat/media channel so the user can see the effect and correct the
308
+ agent's choices. Carry those corrections forward instead of resetting local
309
+ style memory on plugin upgrades.
301
310
 
302
311
  ```bash
303
312
  weclawbotctl preview /path/to/screen-document.json
@@ -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 { createPreviewArtifactDir, writePreviewManifest } from "../lib/preview-manifest.mjs";
13
14
  import { writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
14
15
 
15
16
  const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
@@ -212,6 +213,7 @@ async function commandScreen(values) {
212
213
  timeout: 12,
213
214
  preview: true,
214
215
  "preview-dir": "",
216
+ "preview-manifest": true,
215
217
  scale: 2,
216
218
  });
217
219
  const file = String(options._[0] || "").trim();
@@ -229,9 +231,10 @@ async function commandScreen(values) {
229
231
  if (!validation.ok) {
230
232
  throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
231
233
  }
234
+ const previewOutputDir = options["preview-dir"] || (options["preview-manifest"] ? await createPreviewArtifactDir(document.id || "screen") : "");
232
235
  const preview = options.preview
233
236
  ? await writeScreenDocumentPreviewFiles(document, {
234
- outputDir: options["preview-dir"] || "",
237
+ outputDir: previewOutputDir,
235
238
  scale: Number(options.scale) || 2,
236
239
  })
237
240
  : { available: false, pages: [] };
@@ -250,6 +253,14 @@ async function commandScreen(values) {
250
253
  if (delivery.status.kind !== "applied") {
251
254
  throw new Error(`Device rejected screen document: ${delivery.status.detail || "unknown"}`);
252
255
  }
256
+ const manifest = options["preview-manifest"]
257
+ ? await writePreviewManifest({
258
+ document,
259
+ preview,
260
+ source: "weclawbotctl_screen",
261
+ status: { applied: true, mqtt_status: delivery.status },
262
+ })
263
+ : null;
253
264
  console.log(JSON.stringify({
254
265
  ok: true,
255
266
  published: true,
@@ -260,11 +271,20 @@ async function commandScreen(values) {
260
271
  warnings: validation.warnings,
261
272
  layout_guidance: validation.layout_guidance,
262
273
  preview,
274
+ preview_manifest: previewManifestSummary(manifest),
263
275
  status: delivery.status,
264
276
  }));
265
277
  return;
266
278
  }
267
279
  await publishControl(credentials, control);
280
+ const manifest = options["preview-manifest"]
281
+ ? await writePreviewManifest({
282
+ document,
283
+ preview,
284
+ source: "weclawbotctl_screen",
285
+ status: { applied: null },
286
+ })
287
+ : null;
268
288
  console.log(JSON.stringify({
269
289
  ok: true,
270
290
  published: true,
@@ -275,6 +295,7 @@ async function commandScreen(values) {
275
295
  warnings: validation.warnings,
276
296
  layout_guidance: validation.layout_guidance,
277
297
  preview,
298
+ preview_manifest: previewManifestSummary(manifest),
278
299
  }));
279
300
  }
280
301
 
@@ -767,6 +788,15 @@ function clearStatusDetail(target) {
767
788
  return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
768
789
  }
769
790
 
791
+ function previewManifestSummary(manifest) {
792
+ if (!manifest) return null;
793
+ return {
794
+ id: manifest.id,
795
+ path: manifest.path,
796
+ pages: manifest.preview?.pages?.length || 0,
797
+ };
798
+ }
799
+
770
800
  function shellValue(value) {
771
801
  return `'${String(value).replaceAll("'", "'\\''")}'`;
772
802
  }
@@ -785,7 +815,7 @@ function usage() {
785
815
  weclawbotctl thinking [--ttl seconds] [--id correlation-id]
786
816
  weclawbotctl idle [--id correlation-id]
787
817
  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]
818
+ weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir] [--no-preview] [--no-preview-manifest]
789
819
  weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
790
820
  weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
791
821
  weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
package/index.mjs CHANGED
@@ -10,11 +10,13 @@ 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
12
  import { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "./lib/openclaw-preview.mjs";
13
+ import { createPreviewArtifactDir, listRecentPreviewManifests, markPreviewManifest, writePreviewManifest } from "./lib/preview-manifest.mjs";
13
14
  import { renderScreenDocumentPreviewPages, writeScreenDocumentPreviewFiles } from "./lib/screen-preview.mjs";
14
15
 
15
16
  const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
16
17
  const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
17
18
  const activeRunActivities = new Map();
19
+ const activePreviewWindows = new Map();
18
20
 
19
21
  // The long-running curator bridge remains a separate service. These tools keep
20
22
  // the local agent path explicit: validate first, then publish only with the
@@ -136,12 +138,21 @@ const pluginEntry = defineToolPlugin({
136
138
  document: outbound,
137
139
  };
138
140
  const credentials = await requireCredentials(credentials_path);
139
- const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: preview_output_dir || "" });
141
+ const previewDir = preview_output_dir || await createPreviewArtifactDir(outbound.id || "screen");
142
+ const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: previewDir });
140
143
  if (wait_status !== false) {
141
144
  const delivery = await publishControlAndWaitStatus(credentials, control, {
142
145
  expectedDetail: outbound.id,
143
146
  timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
144
147
  });
148
+ const manifest = delivery.status.kind === "applied"
149
+ ? await writePreviewManifest({
150
+ document: outbound,
151
+ preview,
152
+ source: "openclaw_tool",
153
+ status: { applied: true, mqtt_status: delivery.status },
154
+ })
155
+ : null;
145
156
  return {
146
157
  ok: delivery.status.kind === "applied",
147
158
  published: true,
@@ -153,10 +164,17 @@ const pluginEntry = defineToolPlugin({
153
164
  warnings: validation.warnings,
154
165
  layout_guidance: validation.layout_guidance,
155
166
  preview,
167
+ preview_manifest: previewManifestSummary(manifest),
156
168
  status: delivery.status,
157
169
  };
158
170
  }
159
171
  await publishControl(credentials, control);
172
+ const manifest = await writePreviewManifest({
173
+ document: outbound,
174
+ preview,
175
+ source: "openclaw_tool",
176
+ status: { applied: null },
177
+ });
160
178
  return {
161
179
  ok: true,
162
180
  published: true,
@@ -167,6 +185,7 @@ const pluginEntry = defineToolPlugin({
167
185
  warnings: validation.warnings,
168
186
  layout_guidance: validation.layout_guidance,
169
187
  preview,
188
+ preview_manifest: previewManifestSummary(manifest),
170
189
  };
171
190
  },
172
191
  }),
@@ -228,11 +247,11 @@ function registerOpenClawHooks(api) {
228
247
  return { outcome: "pass" };
229
248
  }, { timeoutMs: 5_000 });
230
249
  api.on("before_agent_finalize", async (event, ctx) => {
231
- await finishHookActivity(api, event, ctx);
250
+ await finishHookActivity(api, event, ctx, { final: false });
232
251
  return { action: "continue" };
233
252
  }, { timeoutMs: 5_000 });
234
253
  api.on("agent_end", async (event, ctx) => {
235
- await finishHookActivity(api, event, ctx);
254
+ await finishHookActivity(api, event, ctx, { final: true });
236
255
  }, { timeoutMs: 5_000 });
237
256
  api.on("after_tool_call", async (event, ctx) => {
238
257
  await attachScreenPreview(api, event, ctx);
@@ -241,10 +260,14 @@ function registerOpenClawHooks(api) {
241
260
 
242
261
  async function startHookActivity(api, event, ctx) {
243
262
  try {
244
- if (api.pluginConfig?.auto_activity === false) return;
245
263
  if (!shouldAutoActivity(event, ctx)) return;
246
264
  const key = hookActivityKey(event, ctx);
247
- if (!key || activeRunActivities.has(key)) return;
265
+ if (!key) return;
266
+ if (!activePreviewWindows.has(key)) {
267
+ activePreviewWindows.set(key, { startedAt: Date.now() });
268
+ }
269
+ if (api.pluginConfig?.auto_activity === false) return;
270
+ if (activeRunActivities.has(key)) return;
248
271
  const correlationId = `openclaw-${sanitizeId(ctx?.runId || key).slice(0, 72)}`;
249
272
  await publishControl(await requireCredentials(), {
250
273
  schema: "weclawbot.control.v1",
@@ -264,10 +287,20 @@ async function startHookActivity(api, event, ctx) {
264
287
  }
265
288
  }
266
289
 
267
- async function finishHookActivity(api, event, ctx) {
290
+ async function finishHookActivity(api, event, ctx, options = {}) {
291
+ const key = hookActivityKey(event, ctx);
292
+ if (!key) return;
293
+ const previewWindow = activePreviewWindows.get(key);
294
+ if (previewWindow) {
295
+ try {
296
+ await attachRecentPreviewManifests(api, event, ctx, previewWindow);
297
+ } catch (error) {
298
+ api.logger?.warn?.(`weclawbot preview manifest hook failed: ${errorMessage(error)}`);
299
+ } finally {
300
+ if (options.final) activePreviewWindows.delete(key);
301
+ }
302
+ }
268
303
  try {
269
- const key = hookActivityKey(event, ctx);
270
- if (!key) return;
271
304
  const active = activeRunActivities.get(key);
272
305
  if (!active) return;
273
306
  activeRunActivities.delete(key);
@@ -287,6 +320,53 @@ async function finishHookActivity(api, event, ctx) {
287
320
  }
288
321
  }
289
322
 
323
+ async function attachRecentPreviewManifests(api, event, ctx, previewWindow) {
324
+ if (api.pluginConfig?.auto_preview === false) return 0;
325
+ if (event?.error) return 0;
326
+ const sessionKey = resolveHookSessionKey(event, ctx);
327
+ if (!sessionKey) {
328
+ logPreviewSkip(api, event, "missing sessionKey for manifest delivery");
329
+ return 0;
330
+ }
331
+ const manifests = await listRecentPreviewManifests({
332
+ sinceMs: Math.max(0, Number(previewWindow?.startedAt) || Date.now() - 60_000),
333
+ untilMs: Date.now() + 10_000,
334
+ limit: 5,
335
+ });
336
+ let delivered = 0;
337
+ for (const manifest of manifests) {
338
+ const result = await attachPreviewManifest(api, manifest, { ...ctx, sessionKey });
339
+ if (result?.ok) {
340
+ delivered += result.count || 1;
341
+ await markPreviewManifest(manifest.path, {
342
+ delivered_at: new Date().toISOString(),
343
+ delivered_session_key: sessionKey,
344
+ delivered_channel: result.channel,
345
+ });
346
+ } else {
347
+ await markPreviewManifest(manifest.path, {
348
+ last_delivery_error_at: new Date().toISOString(),
349
+ last_delivery_error: result?.error || "unknown error",
350
+ });
351
+ api.logger?.warn?.(`weclawbot preview manifest delivery failed: ${result?.error || "unknown error"}`);
352
+ }
353
+ }
354
+ return delivered;
355
+ }
356
+
357
+ async function attachPreviewManifest(api, manifest, ctx) {
358
+ const pages = Array.isArray(manifest.preview?.pages) ? manifest.preview.pages : [];
359
+ const files = pages
360
+ .map((page) => ({ path: String(page.path || "") }))
361
+ .filter((file) => file.path);
362
+ if (files.length === 0) return { ok: false, error: "manifest has no preview files" };
363
+ const documentId = manifest.document?.id || "screen";
364
+ const pageLabel = `${files.length} page${files.length === 1 ? "" : "s"}`;
365
+ const caption = `WeClawBot screen preview: ${documentId} (${pageLabel}, ${manifest.source || "manifest"})`;
366
+ const dir = manifest.preview?.output_dir || path.dirname(files[0].path);
367
+ return deliverPreviewFiles(api, { ctx, files, caption, dir });
368
+ }
369
+
290
370
  async function attachScreenPreview(api, event, ctx) {
291
371
  try {
292
372
  if (api.pluginConfig?.auto_preview !== true) return;
@@ -343,12 +423,22 @@ async function attachPreviewForDocument(api, document, ctx, source) {
343
423
  }
344
424
  const pageLabel = `${previewPages.length} page${previewPages.length === 1 ? "" : "s"}`;
345
425
  const caption = `WeClawBot screen preview: ${document.id || "screen"} (${pageLabel}, ${source})`;
426
+ const result = await deliverPreviewFiles(api, { ctx, files, caption, dir });
427
+ if (!result?.ok) {
428
+ api.logger?.warn?.(`weclawbot preview delivery failed: ${result?.error || "unknown error"}`);
429
+ } else {
430
+ api.logger?.info?.(`weclawbot preview attached: ${result.channel} count=${result.count}`);
431
+ }
432
+ scheduleRemove(dir);
433
+ }
434
+
435
+ async function deliverPreviewFiles(api, params) {
346
436
  let attached = false;
347
437
  if (typeof api.session?.workflow?.sendSessionAttachment === "function") {
348
438
  const result = await api.session.workflow.sendSessionAttachment({
349
- sessionKey: ctx.sessionKey,
350
- files,
351
- text: caption,
439
+ sessionKey: params.ctx.sessionKey,
440
+ files: params.files,
441
+ text: params.caption,
352
442
  maxBytes: 2_000_000,
353
443
  captionFormat: "plain",
354
444
  channelHints: { telegram: { forceDocumentMime: "image/png" } },
@@ -362,20 +452,19 @@ async function attachPreviewForDocument(api, document, ctx, source) {
362
452
  }
363
453
  if (!attached) {
364
454
  const result = await sendPreviewViaOutboundAdapter(api, {
365
- ctx,
366
- files,
367
- document,
368
- source,
369
- caption,
370
- dir,
455
+ ctx: params.ctx,
456
+ files: params.files,
457
+ caption: params.caption,
458
+ dir: params.dir,
371
459
  });
372
460
  if (!result?.ok) {
373
- api.logger?.warn?.(`weclawbot preview outbound fallback failed: ${result?.error || "unknown error"}`);
461
+ return result;
374
462
  } else {
375
463
  api.logger?.info?.(`weclawbot preview attached via outbound adapter: ${result.channel} count=${result.count}`);
464
+ return result;
376
465
  }
377
466
  }
378
- scheduleRemove(dir);
467
+ return { ok: true, channel: "session-workflow", count: params.files.length };
379
468
  }
380
469
 
381
470
  async function sendPreviewViaOutboundAdapter(api, params) {
@@ -502,6 +591,15 @@ function clearStatusDetail(target) {
502
591
  return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
503
592
  }
504
593
 
594
+ function previewManifestSummary(manifest) {
595
+ if (!manifest) return null;
596
+ return {
597
+ id: manifest.id,
598
+ path: manifest.path,
599
+ pages: manifest.preview?.pages?.length || 0,
600
+ };
601
+ }
602
+
505
603
  function sanitizeId(value) {
506
604
  return String(value || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/gu, "_");
507
605
  }
@@ -0,0 +1,133 @@
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
+
6
+ const MANIFEST_SCHEMA = "weclawbot.preview_manifest.v1";
7
+
8
+ export async function defaultPreviewManifestRoot() {
9
+ const override = expandPath(process.env.WECLAWBOT_PREVIEW_SPOOL_DIR || "");
10
+ if (override) return override;
11
+
12
+ const stateDir = expandPath(process.env.OPENCLAW_STATE_DIR || "");
13
+ if (stateDir) return path.join(stateDir, "media", "outbound", "weclawbot-preview");
14
+
15
+ const openClawOutbound = path.join(os.homedir(), ".openclaw", "media", "outbound");
16
+ if (await isDirectory(openClawOutbound)) return path.join(openClawOutbound, "weclawbot-preview");
17
+
18
+ return path.join(os.homedir(), ".cache", "weclawbot", "openclaw-preview");
19
+ }
20
+
21
+ export async function createPreviewArtifactDir(documentId = "screen") {
22
+ const root = await defaultPreviewManifestRoot();
23
+ const now = new Date();
24
+ const stamp = now.toISOString().replace(/[:.]/gu, "-");
25
+ const dir = path.join(root, "files", `${stamp}-${process.pid}-${safeFilename(documentId)}`);
26
+ await fs.mkdir(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ export async function writePreviewManifest({ document, preview, source = "unknown", status = {}, createdAtMs = Date.now() }) {
31
+ if (!preview?.available || !Array.isArray(preview.pages) || preview.pages.length === 0) return null;
32
+ const root = await defaultPreviewManifestRoot();
33
+ const dir = path.join(root, "manifests");
34
+ await fs.mkdir(dir, { recursive: true });
35
+
36
+ const id = `preview_${crypto.randomUUID()}`;
37
+ const createdAt = new Date(createdAtMs).toISOString();
38
+ const manifest = {
39
+ schema: MANIFEST_SCHEMA,
40
+ id,
41
+ created_at: createdAt,
42
+ created_ms: createdAtMs,
43
+ source,
44
+ document: {
45
+ id: String(document?.id || "screen"),
46
+ pages: Array.isArray(document?.pages) ? document.pages.length : preview.pages.length,
47
+ },
48
+ status,
49
+ preview: {
50
+ available: true,
51
+ output_dir: preview.output_dir || path.dirname(preview.pages[0]?.path || ""),
52
+ pages: preview.pages.map((page) => ({
53
+ index: Number(page.index) || 0,
54
+ path: String(page.path || ""),
55
+ width: Number(page.width) || 0,
56
+ height: Number(page.height) || 0,
57
+ scale: Number(page.scale) || 1,
58
+ bytes: Number(page.bytes) || 0,
59
+ sha256: String(page.sha256 || ""),
60
+ mime_type: page.mime_type || "image/png",
61
+ })),
62
+ },
63
+ };
64
+
65
+ const file = path.join(dir, `${createdAtMs}-${safeFilename(document?.id || source)}-${id}.json`);
66
+ await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
67
+ return { ...manifest, path: file, root };
68
+ }
69
+
70
+ export async function listRecentPreviewManifests(options = {}) {
71
+ const root = await defaultPreviewManifestRoot();
72
+ const dir = path.join(root, "manifests");
73
+ const sinceMs = Number(options.sinceMs) || 0;
74
+ const untilMs = Number(options.untilMs) || Date.now() + 10_000;
75
+ const limit = Math.max(1, Number(options.limit) || 8);
76
+ let entries = [];
77
+ try {
78
+ entries = await fs.readdir(dir, { withFileTypes: true });
79
+ } catch (error) {
80
+ if (error?.code === "ENOENT") return [];
81
+ throw error;
82
+ }
83
+
84
+ const manifests = [];
85
+ for (const entry of entries) {
86
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
87
+ const file = path.join(dir, entry.name);
88
+ let stat;
89
+ let manifest;
90
+ try {
91
+ stat = await fs.stat(file);
92
+ if (stat.mtimeMs < sinceMs - 10_000 || stat.mtimeMs > untilMs + 10_000) continue;
93
+ manifest = JSON.parse(await fs.readFile(file, "utf8"));
94
+ } catch {
95
+ continue;
96
+ }
97
+ if (manifest?.schema !== MANIFEST_SCHEMA) continue;
98
+ if (manifest.delivered_at || manifest.consumed_at) continue;
99
+ const createdMs = Number(manifest.created_ms) || Date.parse(manifest.created_at || "") || stat.mtimeMs;
100
+ if (createdMs < sinceMs || createdMs > untilMs) continue;
101
+ const pages = Array.isArray(manifest.preview?.pages) ? manifest.preview.pages : [];
102
+ if (pages.length === 0) continue;
103
+ manifests.push({ ...manifest, created_ms: createdMs, path: file, root });
104
+ }
105
+
106
+ return manifests
107
+ .sort((a, b) => a.created_ms - b.created_ms)
108
+ .slice(0, limit);
109
+ }
110
+
111
+ export async function markPreviewManifest(file, patch) {
112
+ const payload = JSON.parse(await fs.readFile(file, "utf8"));
113
+ const updated = { ...payload, ...patch };
114
+ await fs.writeFile(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
115
+ return updated;
116
+ }
117
+
118
+ async function isDirectory(file) {
119
+ try {
120
+ return (await fs.stat(file)).isDirectory();
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function expandPath(value) {
127
+ const raw = String(value || "").trim();
128
+ return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
129
+ }
130
+
131
+ function safeFilename(value) {
132
+ return String(value || "screen").replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 80) || "screen";
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "WeClawBot pairing and screen-control CLI for local AI agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,11 +43,12 @@
43
43
  "./activity": "./lib/activity.mjs",
44
44
  "./direct-control": "./lib/direct-control.mjs",
45
45
  "./mqtt-control": "./lib/mqtt-control.mjs",
46
+ "./preview-manifest": "./lib/preview-manifest.mjs",
46
47
  "./screen-preview": "./lib/screen-preview.mjs",
47
48
  "./package.json": "./package.json"
48
49
  },
49
50
  "scripts": {
50
- "check": "node --check index.mjs && node --check lib/openclaw-preview.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs test/openclaw-preview-hook.test.mjs"
51
+ "check": "node --check index.mjs && node --check lib/openclaw-preview.mjs && node --check lib/preview-manifest.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/preview-manifest.test.mjs test/openclaw-preview-hook.test.mjs"
51
52
  },
52
53
  "dependencies": {
53
54
  "mqtt": "^5.10.4",
@@ -58,7 +58,10 @@ OpenClaw Telegram/UI sessions, prefer `weclawbot_publish_screen_document`: its
58
58
  result includes `preview.pages[].path` PNG files for the exact mono1 pages. The
59
59
  agent must inspect those preview files when possible, then send the preview PNGs
60
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>`
61
+ "已上屏" reply. This preview is not decoration and not merely proof of delivery:
62
+ it is the feedback surface that lets the user and agent steadily converge on the
63
+ user's reading habits, layout taste, density tolerance, font preferences, and
64
+ page rhythm. If the agent used CLI instead, run `weclawbotctl preview <doc>`
62
65
  before publishing, or read the `preview` field emitted by `weclawbotctl screen`.
63
66
  Do not use OpenClaw Canvas for requests that
64
67
  mention WeClawBot, the physical screen, or “屏上”; Canvas is an OpenClaw UI
@@ -92,9 +95,12 @@ inspect images. Judge the preview against the user's preferences and the agent's
92
95
  own learned standards: legibility, margins, crowding, page count, and continuity
93
96
  across pages. If the preview does not satisfy those standards, regenerate the
94
97
  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.
98
+ the user-visible response so the user can see what was sent and correct the
99
+ agent's choices. Treat explicit comments, repeated corrections, clear requests,
100
+ manual page changes, and acceptance without complaint as signals for future
101
+ layout decisions. This review and feedback loop belongs in the agent/tool layer;
102
+ do not expect firmware to fix typography, show chat previews, or split pages
103
+ after the pixels arrive.
98
104
 
99
105
  `weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
100
106
  device status topic by default. Treat only `applied` as success. If the device
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ createPreviewArtifactDir,
8
+ listRecentPreviewManifests,
9
+ markPreviewManifest,
10
+ writePreviewManifest,
11
+ } from "../lib/preview-manifest.mjs";
12
+ import { writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
13
+
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-manifest-test-"));
15
+ process.env.WECLAWBOT_PREVIEW_SPOOL_DIR = root;
16
+
17
+ const width = 8;
18
+ const height = 8;
19
+ const stride = 1;
20
+ const bytes = Buffer.alloc(stride * height, 0x00);
21
+ for (let y = 0; y < height; y += 1) bytes[y * stride] = 0x80;
22
+
23
+ const document = {
24
+ schema: "weclawbot.screen_document.v1",
25
+ id: "manifest-test",
26
+ pages: [{
27
+ format: "mono1",
28
+ width,
29
+ height,
30
+ stride,
31
+ data_b64: bytes.toString("base64"),
32
+ }],
33
+ };
34
+
35
+ const artifactDir = await createPreviewArtifactDir(document.id);
36
+ assert.ok(artifactDir.startsWith(root));
37
+
38
+ const preview = await writeScreenDocumentPreviewFiles(document, { outputDir: artifactDir });
39
+ const manifest = await writePreviewManifest({
40
+ document,
41
+ preview,
42
+ source: "test",
43
+ status: { applied: true },
44
+ });
45
+
46
+ assert.ok(manifest.path.startsWith(root));
47
+ assert.equal(manifest.preview.pages.length, 1);
48
+
49
+ const recent = await listRecentPreviewManifests({ sinceMs: Date.now() - 10_000 });
50
+ assert.equal(recent.length, 1);
51
+ assert.equal(recent[0].id, manifest.id);
52
+
53
+ await markPreviewManifest(manifest.path, { delivered_at: new Date().toISOString() });
54
+ const afterDelivery = await listRecentPreviewManifests({ sinceMs: Date.now() - 10_000 });
55
+ assert.equal(afterDelivery.length, 0);
56
+
57
+ delete process.env.WECLAWBOT_PREVIEW_SPOOL_DIR;
58
+ await fs.rm(root, { recursive: true, force: true });
59
+
60
+ console.log("preview-manifest spool: ok");
@@ -48,7 +48,11 @@ the tool returns `preview.pages[].path` PNG files for the exact pages. Inspect
48
48
  those preview images when possible, and send them back to the user through the
49
49
  normal chat/media channel before or alongside the success reply. If using CLI,
50
50
  run `weclawbotctl preview <document.json>` before publishing, or read the
51
- `preview` field emitted by `weclawbotctl screen`.
51
+ `preview` field emitted by `weclawbotctl screen`. The preview is the user's
52
+ feedback surface: use it to learn their reading habits, preferred density,
53
+ layout taste, font choices, and page rhythm over time. Do not treat "上屏" as
54
+ complete until the physical screen is updated and the user can see the same
55
+ effect in chat, or you have clearly reported why the preview could not be sent.
52
56
 
53
57
  The firmware receives pixels. Do not send raw text to firmware, and do not
54
58
  answer that direct delivery is unavailable before checking the local