@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 +10 -1
- package/bin/weclawbotctl.mjs +32 -2
- package/index.mjs +117 -19
- package/lib/preview-manifest.mjs +133 -0
- package/package.json +3 -2
- package/skills/weclawbot-curator/SKILL.md +10 -4
- package/test/preview-manifest.test.mjs +60 -0
- package/workspace/AGENTS.md +5 -1
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
|
package/bin/weclawbotctl.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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");
|
package/workspace/AGENTS.md
CHANGED
|
@@ -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
|