@openbrt/weclawbotctl 0.1.18 → 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/bin/weclawbotctl.mjs +32 -2
- package/index.mjs +117 -19
- package/lib/preview-manifest.mjs +133 -0
- package/package.json +3 -2
- package/test/preview-manifest.test.mjs +60 -0
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",
|
|
@@ -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");
|