@openbrt/weclawbotctl 0.1.14 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/bin/weclawbotctl.mjs +23 -0
- package/index.mjs +248 -1
- package/lib/screen-preview.mjs +125 -0
- package/openclaw.plugin.json +10 -1
- package/package.json +3 -2
- package/skills/weclawbot-curator/SKILL.md +7 -5
- package/test/screen-preview.test.mjs +35 -0
- package/workspace/AGENTS.md +5 -0
package/README.md
CHANGED
|
@@ -57,6 +57,12 @@ 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;
|
|
@@ -70,6 +76,9 @@ weclawbotctl screen /path/to/screen-document.json
|
|
|
70
76
|
only after the firmware reports `applied`; a firmware `rejected` status or a
|
|
71
77
|
timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
|
|
72
78
|
publish acknowledgement is enough.
|
|
79
|
+
When the OpenClaw tool `weclawbot_publish_screen_document` is used from a
|
|
80
|
+
Telegram/UI session, the plugin renders the exact mono1 pages back into PNG
|
|
81
|
+
previews and attaches them to the same session when the channel supports files.
|
|
73
82
|
|
|
74
83
|
To clear the current note, use the firmware clear command:
|
|
75
84
|
|
|
@@ -125,7 +134,7 @@ openclaw plugins enable weclawbot
|
|
|
125
134
|
|
|
126
135
|
Restart the OpenClaw gateway or app after installation so it reloads plugin
|
|
127
136
|
tools. The doctor checks the OpenClaw version, plugin installation, plugin
|
|
128
|
-
diagnostics, and local gateway reachability. If a local WSS gateway uses a
|
|
137
|
+
diagnostics, hook permission, and local gateway reachability. If a local WSS gateway uses a
|
|
129
138
|
self-signed certificate, the doctor will suggest `NODE_EXTRA_CA_CERTS`. If the
|
|
130
139
|
certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
|
|
131
140
|
or use a certificate trusted by Node. The package does not rewrite other
|
package/bin/weclawbotctl.mjs
CHANGED
|
@@ -361,6 +361,13 @@ async function commandOpenClawInstall(values) {
|
|
|
361
361
|
if (options.force) installArgs.push("--force");
|
|
362
362
|
await runRequired(openclaw, installArgs);
|
|
363
363
|
await runRequired(openclaw, ["plugins", "enable", "weclawbot"]);
|
|
364
|
+
await runRequired(openclaw, [
|
|
365
|
+
"config",
|
|
366
|
+
"set",
|
|
367
|
+
"plugins.entries.weclawbot.hooks.allowConversationAccess",
|
|
368
|
+
"true",
|
|
369
|
+
"--strict-json",
|
|
370
|
+
]);
|
|
364
371
|
console.log("OpenClaw plugin installed and enabled. Restart the OpenClaw gateway or app so it reloads plugins.");
|
|
365
372
|
if (options.doctor) {
|
|
366
373
|
await commandOpenClawDoctor(["--bin", openclaw, "--gateway=false"]);
|
|
@@ -402,6 +409,22 @@ async function commandOpenClawDoctor(values) {
|
|
|
402
409
|
hint: weclawbotIssue ? "Upgrade and reinstall: weclawbotctl openclaw install" : "",
|
|
403
410
|
});
|
|
404
411
|
|
|
412
|
+
const hooksAccess = await runCaptured(openclaw, [
|
|
413
|
+
"config",
|
|
414
|
+
"get",
|
|
415
|
+
"plugins.entries.weclawbot.hooks.allowConversationAccess",
|
|
416
|
+
"--json",
|
|
417
|
+
], { timeoutMs });
|
|
418
|
+
const hooksEnabled = hooksAccess.code === 0 && /^\s*true\s*$/iu.test(hooksAccess.stdout);
|
|
419
|
+
checks.push({
|
|
420
|
+
name: "openclaw_weclawbot_hooks",
|
|
421
|
+
ok: hooksEnabled,
|
|
422
|
+
detail: hooksEnabled
|
|
423
|
+
? "conversation hooks enabled for automatic thinking state"
|
|
424
|
+
: compactText(hooksAccess.stderr || hooksAccess.stdout || "hooks.allowConversationAccess is not enabled"),
|
|
425
|
+
hint: hooksEnabled ? "" : "Run: openclaw config set plugins.entries.weclawbot.hooks.allowConversationAccess true --strict-json",
|
|
426
|
+
});
|
|
427
|
+
|
|
405
428
|
if (options.gateway) {
|
|
406
429
|
const gatewayEnv = { ...process.env };
|
|
407
430
|
const defaultCert = path.join(os.homedir(), ".openclaw", "gateway", "tls", "gateway-cert.pem");
|
package/index.mjs
CHANGED
|
@@ -9,16 +9,23 @@ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
|
|
9
9
|
import { validateActivity } from "./lib/activity.mjs";
|
|
10
10
|
import { validateScreenDocument } from "./lib/direct-control.mjs";
|
|
11
11
|
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "./lib/mqtt-control.mjs";
|
|
12
|
+
import { previewSummary, renderScreenDocumentPreviewPages } from "./lib/screen-preview.mjs";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
15
|
+
const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
|
|
16
|
+
const activeRunActivities = new Map();
|
|
14
17
|
|
|
15
18
|
// The long-running curator bridge remains a separate service. These tools keep
|
|
16
19
|
// the local agent path explicit: validate first, then publish only with the
|
|
17
20
|
// user's paired MQTT credential.
|
|
18
|
-
|
|
21
|
+
const pluginEntry = defineToolPlugin({
|
|
19
22
|
id: "weclawbot",
|
|
20
23
|
name: "WeClawBot",
|
|
21
24
|
description: "WeClawBot screen-curation skill pack and direct MQTT control tools.",
|
|
25
|
+
configSchema: Type.Object({
|
|
26
|
+
auto_activity: Type.Optional(Type.Boolean()),
|
|
27
|
+
auto_preview: Type.Optional(Type.Boolean()),
|
|
28
|
+
}, { additionalProperties: false }),
|
|
22
29
|
tools: (tool) => [
|
|
23
30
|
tool({
|
|
24
31
|
name: "weclawbot_status",
|
|
@@ -127,6 +134,8 @@ export default defineToolPlugin({
|
|
|
127
134
|
document: outbound,
|
|
128
135
|
};
|
|
129
136
|
const credentials = await requireCredentials(credentials_path);
|
|
137
|
+
const previewPages = renderScreenDocumentPreviewPages(outbound);
|
|
138
|
+
const preview = previewSummary(previewPages);
|
|
130
139
|
if (wait_status !== false) {
|
|
131
140
|
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
132
141
|
expectedDetail: outbound.id,
|
|
@@ -142,6 +151,7 @@ export default defineToolPlugin({
|
|
|
142
151
|
force_replace: outbound.force_replace === true,
|
|
143
152
|
warnings: validation.warnings,
|
|
144
153
|
layout_guidance: validation.layout_guidance,
|
|
154
|
+
preview,
|
|
145
155
|
status: delivery.status,
|
|
146
156
|
};
|
|
147
157
|
}
|
|
@@ -155,6 +165,7 @@ export default defineToolPlugin({
|
|
|
155
165
|
force_replace: outbound.force_replace === true,
|
|
156
166
|
warnings: validation.warnings,
|
|
157
167
|
layout_guidance: validation.layout_guidance,
|
|
168
|
+
preview,
|
|
158
169
|
};
|
|
159
170
|
},
|
|
160
171
|
}),
|
|
@@ -201,6 +212,218 @@ export default defineToolPlugin({
|
|
|
201
212
|
],
|
|
202
213
|
});
|
|
203
214
|
|
|
215
|
+
const registerTools = pluginEntry.register;
|
|
216
|
+
pluginEntry.register = (api) => {
|
|
217
|
+
registerTools(api);
|
|
218
|
+
registerOpenClawHooks(api);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export default pluginEntry;
|
|
222
|
+
|
|
223
|
+
function registerOpenClawHooks(api) {
|
|
224
|
+
if (!api || typeof api.on !== "function") return;
|
|
225
|
+
api.on("before_agent_run", async (event, ctx) => {
|
|
226
|
+
await startHookActivity(api, event, ctx);
|
|
227
|
+
return { outcome: "pass" };
|
|
228
|
+
}, { timeoutMs: 5_000 });
|
|
229
|
+
api.on("before_agent_finalize", async (event, ctx) => {
|
|
230
|
+
await finishHookActivity(api, event, ctx);
|
|
231
|
+
return { action: "continue" };
|
|
232
|
+
}, { timeoutMs: 5_000 });
|
|
233
|
+
api.on("agent_end", async (event, ctx) => {
|
|
234
|
+
await finishHookActivity(api, event, ctx);
|
|
235
|
+
}, { timeoutMs: 5_000 });
|
|
236
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
237
|
+
await attachScreenPreview(api, event, ctx);
|
|
238
|
+
}, { timeoutMs: 10_000 });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function startHookActivity(api, event, ctx) {
|
|
242
|
+
try {
|
|
243
|
+
if (api.pluginConfig?.auto_activity === false) return;
|
|
244
|
+
if (!shouldAutoActivity(event, ctx)) return;
|
|
245
|
+
const key = hookActivityKey(event, ctx);
|
|
246
|
+
if (!key || activeRunActivities.has(key)) return;
|
|
247
|
+
const correlationId = `openclaw-${sanitizeId(ctx?.runId || key).slice(0, 72)}`;
|
|
248
|
+
await publishControl(await requireCredentials(), {
|
|
249
|
+
schema: "weclawbot.control.v1",
|
|
250
|
+
id: `activity_${crypto.randomUUID()}`,
|
|
251
|
+
kind: "activity",
|
|
252
|
+
activity: {
|
|
253
|
+
schema: "weclawbot.activity.v1",
|
|
254
|
+
state: "thinking",
|
|
255
|
+
correlation_id: correlationId,
|
|
256
|
+
ttl_seconds: 120,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
activeRunActivities.set(key, { correlationId, startedAt: Date.now() });
|
|
260
|
+
api.logger?.info?.(`weclawbot activity thinking sent for ${key}`);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
api.logger?.debug?.(`weclawbot activity hook skipped: ${errorMessage(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function finishHookActivity(api, event, ctx) {
|
|
267
|
+
try {
|
|
268
|
+
const key = hookActivityKey(event, ctx);
|
|
269
|
+
if (!key) return;
|
|
270
|
+
const active = activeRunActivities.get(key);
|
|
271
|
+
if (!active) return;
|
|
272
|
+
activeRunActivities.delete(key);
|
|
273
|
+
await publishControl(await requireCredentials(), {
|
|
274
|
+
schema: "weclawbot.control.v1",
|
|
275
|
+
id: `activity_${crypto.randomUUID()}`,
|
|
276
|
+
kind: "activity",
|
|
277
|
+
activity: {
|
|
278
|
+
schema: "weclawbot.activity.v1",
|
|
279
|
+
state: "idle",
|
|
280
|
+
correlation_id: active.correlationId,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
api.logger?.info?.(`weclawbot activity idle sent for ${key}`);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
api.logger?.debug?.(`weclawbot activity idle hook skipped: ${errorMessage(error)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function attachScreenPreview(api, event, ctx) {
|
|
290
|
+
try {
|
|
291
|
+
if (api.pluginConfig?.auto_preview === false) return;
|
|
292
|
+
if (event?.error) return;
|
|
293
|
+
if (!ctx?.sessionKey || typeof api.session?.workflow?.sendSessionAttachment !== "function") return;
|
|
294
|
+
let document = null;
|
|
295
|
+
let source = "tool";
|
|
296
|
+
if (event?.toolName === "weclawbot_publish_screen_document") {
|
|
297
|
+
document = cloneObject(event.params?.document);
|
|
298
|
+
if (event.params?.force_replace) {
|
|
299
|
+
document.force_replace = true;
|
|
300
|
+
document.base_revision = "*";
|
|
301
|
+
}
|
|
302
|
+
} else if (isExecTool(event?.toolName)) {
|
|
303
|
+
const file = extractScreenDocumentPathFromExecParams(event.params);
|
|
304
|
+
if (!file) return;
|
|
305
|
+
document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
306
|
+
source = "cli";
|
|
307
|
+
} else {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await attachPreviewForDocument(api, document, ctx, source);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
api.logger?.debug?.(`weclawbot preview attachment skipped: ${errorMessage(error)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function attachPreviewForDocument(api, document, ctx, source) {
|
|
317
|
+
const validation = validateScreenDocument(document, {
|
|
318
|
+
agent_transport: { available: true, screen_document_available: true },
|
|
319
|
+
});
|
|
320
|
+
if (!validation.ok) return;
|
|
321
|
+
const previewPages = renderScreenDocumentPreviewPages(document);
|
|
322
|
+
if (previewPages.length === 0) return;
|
|
323
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
|
|
324
|
+
const files = [];
|
|
325
|
+
for (const page of previewPages) {
|
|
326
|
+
const file = path.join(dir, `${safeFilename(document.id || "screen")}-p${page.index + 1}.png`);
|
|
327
|
+
await fs.writeFile(file, page.png);
|
|
328
|
+
files.push({ path: file });
|
|
329
|
+
}
|
|
330
|
+
const pageLabel = `${previewPages.length} page${previewPages.length === 1 ? "" : "s"}`;
|
|
331
|
+
await api.session.workflow.sendSessionAttachment({
|
|
332
|
+
sessionKey: ctx.sessionKey,
|
|
333
|
+
files,
|
|
334
|
+
text: `WeClawBot screen preview: ${document.id || "screen"} (${pageLabel}, ${source})`,
|
|
335
|
+
maxBytes: 2_000_000,
|
|
336
|
+
});
|
|
337
|
+
scheduleRemove(dir);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function shouldAutoActivity(event, ctx) {
|
|
341
|
+
if (String(ctx?.trigger || "").includes("curator")) return false;
|
|
342
|
+
const prompt = String(event?.prompt || "");
|
|
343
|
+
if (prompt.includes("WECLAWBOT_CURATOR_EVENT")) return false;
|
|
344
|
+
return SCREEN_PROMPT_PATTERN.test(prompt);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hookActivityKey(event, ctx) {
|
|
348
|
+
if (ctx?.runId || event?.runId) return `run:${ctx?.runId || event?.runId}`;
|
|
349
|
+
if (ctx?.sessionKey) return `session:${ctx.sessionKey}`;
|
|
350
|
+
return "";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isExecTool(name) {
|
|
354
|
+
return /(^|[_-])(exec|shell|command)($|[_-])/iu.test(String(name || ""));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function extractScreenDocumentPathFromExecParams(params) {
|
|
358
|
+
const command = collectCommandStrings(params).join("\n");
|
|
359
|
+
if (!/weclawbotctl\s+screen\b/u.test(command)) return "";
|
|
360
|
+
for (const line of command.split(/\r?\n/u)) {
|
|
361
|
+
const match = line.match(/(?:^|\s)(?:[^\s;&|]*\/)?weclawbotctl\s+screen\b([^;&|\n]*)/u);
|
|
362
|
+
if (!match) continue;
|
|
363
|
+
const tokens = shellSplit(match[1] || "");
|
|
364
|
+
const candidates = [];
|
|
365
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
366
|
+
const token = tokens[index];
|
|
367
|
+
if (!token) continue;
|
|
368
|
+
if (token.startsWith("--")) {
|
|
369
|
+
const key = token.split("=", 1)[0];
|
|
370
|
+
if (!token.includes("=") && new Set(["--credentials", "--timeout"]).has(key)) index += 1;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
candidates.push(token);
|
|
374
|
+
}
|
|
375
|
+
const picked = candidates.findLast((token) => token.endsWith(".json")) || candidates.at(-1) || "";
|
|
376
|
+
if (picked) return expandPathWithBase(picked, params?.cwd || params?.workdir || process.cwd());
|
|
377
|
+
}
|
|
378
|
+
return "";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function collectCommandStrings(value, depth = 0) {
|
|
382
|
+
if (depth > 3 || value == null) return [];
|
|
383
|
+
if (typeof value === "string") return [value];
|
|
384
|
+
if (Array.isArray(value)) return value.flatMap((item) => collectCommandStrings(item, depth + 1));
|
|
385
|
+
if (typeof value !== "object") return [];
|
|
386
|
+
const wanted = ["cmd", "command", "script", "input", "args", "argv"];
|
|
387
|
+
return wanted.flatMap((key) => collectCommandStrings(value[key], depth + 1));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function shellSplit(value) {
|
|
391
|
+
const tokens = [];
|
|
392
|
+
let token = "";
|
|
393
|
+
let quote = "";
|
|
394
|
+
let escaped = false;
|
|
395
|
+
for (const ch of String(value || "")) {
|
|
396
|
+
if (escaped) {
|
|
397
|
+
token += ch;
|
|
398
|
+
escaped = false;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (ch === "\\") {
|
|
402
|
+
escaped = true;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (quote) {
|
|
406
|
+
if (ch === quote) quote = "";
|
|
407
|
+
else token += ch;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (ch === "'" || ch === "\"") {
|
|
411
|
+
quote = ch;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (/\s/u.test(ch)) {
|
|
415
|
+
if (token) {
|
|
416
|
+
tokens.push(token);
|
|
417
|
+
token = "";
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
token += ch;
|
|
422
|
+
}
|
|
423
|
+
if (token) tokens.push(token);
|
|
424
|
+
return tokens;
|
|
425
|
+
}
|
|
426
|
+
|
|
204
427
|
async function requireCredentials(credentialsPath) {
|
|
205
428
|
const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
|
|
206
429
|
const payload = await readCredentials(file);
|
|
@@ -222,6 +445,11 @@ function expandPath(value) {
|
|
|
222
445
|
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
223
446
|
}
|
|
224
447
|
|
|
448
|
+
function expandPathWithBase(value, base) {
|
|
449
|
+
const expanded = expandPath(value);
|
|
450
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(String(base || process.cwd()), expanded);
|
|
451
|
+
}
|
|
452
|
+
|
|
225
453
|
function cloneObject(value) {
|
|
226
454
|
if (!value || typeof value !== "object") throw new Error("document must be an object");
|
|
227
455
|
return JSON.parse(JSON.stringify(value));
|
|
@@ -239,6 +467,25 @@ function clearStatusDetail(target) {
|
|
|
239
467
|
return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
|
|
240
468
|
}
|
|
241
469
|
|
|
470
|
+
function sanitizeId(value) {
|
|
471
|
+
return String(value || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/gu, "_");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function safeFilename(value) {
|
|
475
|
+
return sanitizeId(value).slice(0, 80) || "screen";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function scheduleRemove(dir) {
|
|
479
|
+
const timer = setTimeout(() => {
|
|
480
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
481
|
+
}, 60_000);
|
|
482
|
+
timer.unref?.();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function errorMessage(error) {
|
|
486
|
+
return String(error instanceof Error ? error.message : error).replace(/\s+/gu, " ").trim().slice(0, 240);
|
|
487
|
+
}
|
|
488
|
+
|
|
242
489
|
function maskedMqtt(config, topics) {
|
|
243
490
|
return {
|
|
244
491
|
url: config.url,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { deflateSync } from "node:zlib";
|
|
3
|
+
|
|
4
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
5
|
+
|
|
6
|
+
export function renderScreenDocumentPreviewPages(document, options = {}) {
|
|
7
|
+
const pages = Array.isArray(document?.pages) ? document.pages : [];
|
|
8
|
+
const scale = clampInteger(options.scale ?? 2, 1, 4);
|
|
9
|
+
return pages.map((page, index) => {
|
|
10
|
+
const png = renderMonoPagePreview(page, { scale });
|
|
11
|
+
return {
|
|
12
|
+
index,
|
|
13
|
+
width: Number(page.width) * scale,
|
|
14
|
+
height: Number(page.height) * scale,
|
|
15
|
+
scale,
|
|
16
|
+
png,
|
|
17
|
+
bytes: png.length,
|
|
18
|
+
sha256: crypto.createHash("sha256").update(png).digest("hex"),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function previewSummary(previewPages) {
|
|
24
|
+
return {
|
|
25
|
+
available: previewPages.length > 0,
|
|
26
|
+
pages: previewPages.map((page) => ({
|
|
27
|
+
index: page.index,
|
|
28
|
+
width: page.width,
|
|
29
|
+
height: page.height,
|
|
30
|
+
scale: page.scale,
|
|
31
|
+
bytes: page.bytes,
|
|
32
|
+
sha256: page.sha256,
|
|
33
|
+
mime_type: "image/png",
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderMonoPagePreview(page, options = {}) {
|
|
39
|
+
const width = Number(page?.width);
|
|
40
|
+
const height = Number(page?.height);
|
|
41
|
+
const stride = Number(page?.stride);
|
|
42
|
+
const scale = clampInteger(options.scale ?? 2, 1, 4);
|
|
43
|
+
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
|
|
44
|
+
throw new Error("preview page width/height must be positive integers");
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isInteger(stride) || stride < Math.ceil(width / 8)) {
|
|
47
|
+
throw new Error("preview page stride is invalid");
|
|
48
|
+
}
|
|
49
|
+
const packed = Buffer.from(String(page?.data_b64 || ""), "base64");
|
|
50
|
+
if (packed.length < stride * height) {
|
|
51
|
+
throw new Error("preview page data is shorter than stride * height");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outWidth = width * scale;
|
|
55
|
+
const outHeight = height * scale;
|
|
56
|
+
const gray = Buffer.alloc(outWidth * outHeight, 0xff);
|
|
57
|
+
for (let y = 0; y < height; y += 1) {
|
|
58
|
+
const row = y * stride;
|
|
59
|
+
for (let x = 0; x < width; x += 1) {
|
|
60
|
+
const black = (packed[row + (x >> 3)] & (0x80 >> (x & 7))) !== 0;
|
|
61
|
+
if (!black) continue;
|
|
62
|
+
for (let dy = 0; dy < scale; dy += 1) {
|
|
63
|
+
const outRow = (y * scale + dy) * outWidth;
|
|
64
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
65
|
+
gray[outRow + x * scale + dx] = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return encodeGrayscalePng(gray, outWidth, outHeight);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function encodeGrayscalePng(gray, width, height) {
|
|
74
|
+
const raw = Buffer.alloc((width + 1) * height);
|
|
75
|
+
for (let y = 0; y < height; y += 1) {
|
|
76
|
+
const row = y * (width + 1);
|
|
77
|
+
raw[row] = 0;
|
|
78
|
+
gray.copy(raw, row + 1, y * width, (y + 1) * width);
|
|
79
|
+
}
|
|
80
|
+
const ihdr = Buffer.alloc(13);
|
|
81
|
+
ihdr.writeUInt32BE(width, 0);
|
|
82
|
+
ihdr.writeUInt32BE(height, 4);
|
|
83
|
+
ihdr[8] = 8;
|
|
84
|
+
ihdr[9] = 0;
|
|
85
|
+
ihdr[10] = 0;
|
|
86
|
+
ihdr[11] = 0;
|
|
87
|
+
ihdr[12] = 0;
|
|
88
|
+
return Buffer.concat([
|
|
89
|
+
PNG_SIGNATURE,
|
|
90
|
+
pngChunk("IHDR", ihdr),
|
|
91
|
+
pngChunk("IDAT", deflateSync(raw)),
|
|
92
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pngChunk(type, data) {
|
|
97
|
+
const typeBuf = Buffer.from(type, "ascii");
|
|
98
|
+
const len = Buffer.alloc(4);
|
|
99
|
+
len.writeUInt32BE(data.length, 0);
|
|
100
|
+
const crc = Buffer.alloc(4);
|
|
101
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
102
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const crcTable = new Uint32Array(256).map((_, index) => {
|
|
106
|
+
let c = index;
|
|
107
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
108
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
109
|
+
}
|
|
110
|
+
return c >>> 0;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function crc32(buffer) {
|
|
114
|
+
let crc = 0xffffffff;
|
|
115
|
+
for (const byte of buffer) {
|
|
116
|
+
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
117
|
+
}
|
|
118
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clampInteger(value, min, max) {
|
|
122
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
123
|
+
if (!Number.isFinite(parsed)) return min;
|
|
124
|
+
return Math.max(min, Math.min(max, parsed));
|
|
125
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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": "Automatically attach PNG previews after publishing a screen document from an OpenClaw session."
|
|
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.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "WeClawBot pairing and screen-control CLI for local AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
"./activity": "./lib/activity.mjs",
|
|
44
44
|
"./direct-control": "./lib/direct-control.mjs",
|
|
45
45
|
"./mqtt-control": "./lib/mqtt-control.mjs",
|
|
46
|
+
"./screen-preview": "./lib/screen-preview.mjs",
|
|
46
47
|
"./package.json": "./package.json"
|
|
47
48
|
},
|
|
48
49
|
"scripts": {
|
|
49
|
-
"check": "node --check index.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs"
|
|
50
|
+
"check": "node --check index.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"mqtt": "^5.10.4",
|
|
@@ -53,11 +53,13 @@ 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.
|
|
57
|
-
OpenClaw
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
or call `weclawbot_publish_screen_document` with the same document. Inside
|
|
57
|
+
OpenClaw Telegram/UI sessions, prefer `weclawbot_publish_screen_document`: after
|
|
58
|
+
publish it can attach PNG previews of the exact mono1 pages back to the
|
|
59
|
+
conversation. Do not use OpenClaw Canvas for requests that mention WeClawBot, the
|
|
60
|
+
physical screen, or “屏上”; Canvas is an OpenClaw UI surface, not the ESP32
|
|
61
|
+
e-paper display. Do not send raw text to firmware. The agent owns text layout,
|
|
62
|
+
font choice, image rasterization, and page splitting; the device consumes pixels.
|
|
61
63
|
|
|
62
64
|
For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
|
|
63
65
|
current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
import { renderScreenDocumentPreviewPages, previewSummary } from "../lib/screen-preview.mjs";
|
|
4
|
+
|
|
5
|
+
const width = 16;
|
|
6
|
+
const height = 8;
|
|
7
|
+
const stride = 2;
|
|
8
|
+
const bytes = Buffer.alloc(stride * height, 0x00);
|
|
9
|
+
for (let i = 0; i < Math.min(width, height); i += 1) {
|
|
10
|
+
bytes[i * stride + (i >> 3)] |= 0x80 >> (i & 7);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const preview = renderScreenDocumentPreviewPages({
|
|
14
|
+
schema: "weclawbot.screen_document.v1",
|
|
15
|
+
pages: [{
|
|
16
|
+
format: "mono1",
|
|
17
|
+
width,
|
|
18
|
+
height,
|
|
19
|
+
stride,
|
|
20
|
+
data_b64: bytes.toString("base64"),
|
|
21
|
+
}],
|
|
22
|
+
}, { scale: 2 });
|
|
23
|
+
|
|
24
|
+
assert.equal(preview.length, 1);
|
|
25
|
+
assert.equal(preview[0].width, width * 2);
|
|
26
|
+
assert.equal(preview[0].height, height * 2);
|
|
27
|
+
assert.equal(preview[0].png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
|
|
28
|
+
assert.ok(preview[0].bytes > 50);
|
|
29
|
+
assert.match(preview[0].sha256, /^[0-9a-f]{64}$/u);
|
|
30
|
+
|
|
31
|
+
const summary = previewSummary(preview);
|
|
32
|
+
assert.equal(summary.available, true);
|
|
33
|
+
assert.equal(summary.pages[0].mime_type, "image/png");
|
|
34
|
+
|
|
35
|
+
console.log("screen-preview renderer: ok");
|
package/workspace/AGENTS.md
CHANGED
|
@@ -42,6 +42,11 @@ success only after it exits with success. If the user explicitly asks to
|
|
|
42
42
|
replace whatever is currently shown, use `--force`; otherwise use the current
|
|
43
43
|
screen revision in `base_revision`.
|
|
44
44
|
|
|
45
|
+
When running inside OpenClaw with the WeClawBot plugin tools available, prefer
|
|
46
|
+
`weclawbot_publish_screen_document` over shelling out to `weclawbotctl screen`;
|
|
47
|
+
the tool can attach PNG previews of the exact pages back to the chat/UI after a
|
|
48
|
+
successful publish.
|
|
49
|
+
|
|
45
50
|
The firmware receives pixels. Do not send raw text to firmware, and do not
|
|
46
51
|
answer that direct delivery is unavailable before checking the local
|
|
47
52
|
`weclawbotctl` profile.
|