@openbrt/weclawbotctl 0.1.9 → 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 +45 -2
- package/bin/weclawbot-openclaw-bridge.mjs +34 -0
- package/bin/weclawbotctl.mjs +81 -1
- package/index.mjs +301 -1
- package/lib/direct-control.mjs +29 -4
- package/lib/screen-preview.mjs +125 -0
- package/openclaw.plugin.json +11 -1
- package/package.json +3 -2
- package/skills/weclawbot-curator/SKILL.md +40 -8
- package/systemd/weclawbot-openclaw-curator.service +1 -0
- package/test/direct-control.test.mjs +19 -0
- package/test/screen-preview.test.mjs +35 -0
- package/workspace/AGENTS.md +11 -0
package/README.md
CHANGED
|
@@ -52,6 +52,18 @@ weclawbotctl thinking --id "$task_id" --ttl 45
|
|
|
52
52
|
weclawbotctl idle --id "$task_id"
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
`idle` must use the same id as the active `thinking` message. Newer firmware
|
|
56
|
+
rejects stale or unrelated `idle` messages and keeps the visible thinking state.
|
|
57
|
+
The bundled OpenClaw bridge publishes `thinking` before every curator job and
|
|
58
|
+
`idle` after it finishes, so WeChat-origin official-mode work also gets a visible
|
|
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.
|
|
66
|
+
|
|
55
67
|
To put text, status, diagrams, or images on the screen, render them into a
|
|
56
68
|
pre-rendered `weclawbot.screen_document.v1` first. The firmware receives pixels;
|
|
57
69
|
it does not lay out text, choose fonts, or split pages for agents.
|
|
@@ -64,10 +76,23 @@ weclawbotctl screen /path/to/screen-document.json
|
|
|
64
76
|
only after the firmware reports `applied`; a firmware `rejected` status or a
|
|
65
77
|
timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
|
|
66
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.
|
|
82
|
+
|
|
83
|
+
To clear the current note, use the firmware clear command:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
weclawbotctl clear
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Do not emulate clear by publishing a blank, white, or black screen document.
|
|
90
|
+
That creates a new note and can leave the physical screen looking black.
|
|
67
91
|
|
|
68
92
|
The package also includes an OpenClaw integration: the `weclawbot-curator`
|
|
69
93
|
skill, `weclawbot_status`, `weclawbot_validate_screen_document`,
|
|
70
|
-
`
|
|
94
|
+
`weclawbot_clear_screen`, `weclawbot_publish_screen_document`,
|
|
95
|
+
`weclawbot_validate_activity`,
|
|
71
96
|
`weclawbot_publish_activity`, and a small outbound bridge service. The bridge
|
|
72
97
|
polls `weclawbot.link`; no public HTTP endpoint, port forwarding, or WeChat
|
|
73
98
|
credential is required on the OpenClaw host.
|
|
@@ -109,7 +134,7 @@ openclaw plugins enable weclawbot
|
|
|
109
134
|
|
|
110
135
|
Restart the OpenClaw gateway or app after installation so it reloads plugin
|
|
111
136
|
tools. The doctor checks the OpenClaw version, plugin installation, plugin
|
|
112
|
-
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
|
|
113
138
|
self-signed certificate, the doctor will suggest `NODE_EXTRA_CA_CERTS`. If the
|
|
114
139
|
certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
|
|
115
140
|
or use a certificate trusted by Node. The package does not rewrite other
|
|
@@ -248,6 +273,24 @@ three `mono1` pages, and a future UTC expiry. Agents may use PIL, Canvas, SVG
|
|
|
248
273
|
rasterization, screenshots, or any local renderer, but the MQTT payload must be
|
|
249
274
|
pixels:
|
|
250
275
|
|
|
276
|
+
There is no canonical WeClawBot renderer that agents must use. The stable
|
|
277
|
+
contract is the bounded pixel document plus device feedback. Keep layout,
|
|
278
|
+
typography, and page-composition decisions in the agent/tool layer so skills and
|
|
279
|
+
models can improve the result without requiring users to flash firmware.
|
|
280
|
+
Preserve any layout preferences, visual language, page rhythm, font choices, or
|
|
281
|
+
review habits that the user and agent have already developed; package upgrades
|
|
282
|
+
should add capabilities without resetting that accumulated practice.
|
|
283
|
+
|
|
284
|
+
The hardware facts are stable: the content viewport is 368 x 206 mono1 pixels,
|
|
285
|
+
content documents may contain one to three pages, and the firmware will not split
|
|
286
|
+
a single pixel page after receiving it. If `pages.length === 1`, the physical
|
|
287
|
+
screen has exactly one page.
|
|
288
|
+
|
|
289
|
+
Before publishing, agents should inspect or otherwise self-evaluate the rendered
|
|
290
|
+
pages against the user's preferences and their own learned standards when their
|
|
291
|
+
runtime supports it. Regenerate the document if the bitmap does not satisfy those
|
|
292
|
+
standards.
|
|
293
|
+
|
|
251
294
|
```bash
|
|
252
295
|
weclawbotctl screen /path/to/screen-document.json
|
|
253
296
|
```
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
5
6
|
|
|
6
7
|
const ACTIONS = new Set([
|
|
7
8
|
"ignore",
|
|
@@ -53,6 +54,10 @@ function stop() {
|
|
|
53
54
|
|
|
54
55
|
async function handleJob(job) {
|
|
55
56
|
const started = Date.now();
|
|
57
|
+
const activityId = `openclaw-${String(job.id || cryptoRandom()).replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 64)}`;
|
|
58
|
+
await publishBridgeActivity("thinking", activityId, {
|
|
59
|
+
ttlSeconds: Math.min(120, Math.max(5, config.agentTimeoutSeconds + 15)),
|
|
60
|
+
});
|
|
56
61
|
try {
|
|
57
62
|
const decision = await curateWithOpenClaw(job);
|
|
58
63
|
await gatewayJson("POST", `${config.jobsPath}/${encodeURIComponent(job.id)}/result`, {
|
|
@@ -71,6 +76,24 @@ async function handleJob(job) {
|
|
|
71
76
|
} catch (resultError) {
|
|
72
77
|
log("failure_report_failed", { job: job.id, error: errorMessage(resultError) });
|
|
73
78
|
}
|
|
79
|
+
} finally {
|
|
80
|
+
await publishBridgeActivity("idle", activityId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function publishBridgeActivity(state, id, options = {}) {
|
|
85
|
+
if (!config.screenActivity) return;
|
|
86
|
+
const args = [state, "--id", id];
|
|
87
|
+
if (state === "thinking") args.push("--ttl", String(options.ttlSeconds || 45));
|
|
88
|
+
try {
|
|
89
|
+
const result = await run(config.weclawbotctlBin, args, 8000);
|
|
90
|
+
if (result.code !== 0) {
|
|
91
|
+
log("activity_failed", { state, id, error: shortText(result.stderr || result.stdout || `exit ${result.code}`) });
|
|
92
|
+
} else {
|
|
93
|
+
log("activity_sent", { state, id });
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
log("activity_failed", { state, id, error: errorMessage(error) });
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
99
|
|
|
@@ -316,8 +339,10 @@ function loadConfig(env) {
|
|
|
316
339
|
jobsPath: String(env.WEC_GATEWAY_JOBS_PATH || "/api/agent/curator/jobs"),
|
|
317
340
|
agentId: String(env.WEC_OPENCLAW_AGENT || "weclawbot"),
|
|
318
341
|
openclawBin: String(env.WEC_OPENCLAW_BIN || "openclaw"),
|
|
342
|
+
weclawbotctlBin: String(env.WEC_WECLAWBOTCTL_BIN || siblingBin("weclawbotctl")),
|
|
319
343
|
transport: env.WEC_OPENCLAW_TRANSPORT === "local" ? "local" : "gateway",
|
|
320
344
|
thinking: String(env.WEC_OPENCLAW_THINKING || "low"),
|
|
345
|
+
screenActivity: env.WEC_SCREEN_ACTIVITY !== "0" && env.WEC_SCREEN_ACTIVITY !== "false",
|
|
321
346
|
pollWaitMs: positiveInteger(env.WEC_POLL_WAIT_MS, 20000),
|
|
322
347
|
retryMs: positiveInteger(env.WEC_RETRY_MS, 3000),
|
|
323
348
|
agentTimeoutSeconds: positiveInteger(env.WEC_AGENT_TIMEOUT_SECONDS, 20),
|
|
@@ -326,6 +351,15 @@ function loadConfig(env) {
|
|
|
326
351
|
};
|
|
327
352
|
}
|
|
328
353
|
|
|
354
|
+
function siblingBin(name) {
|
|
355
|
+
const script = process.argv[1] || "";
|
|
356
|
+
return script ? path.join(path.dirname(script), name) : name;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function cryptoRandom() {
|
|
360
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
329
363
|
function positiveInteger(value, fallback) {
|
|
330
364
|
const parsed = Number.parseInt(String(value || ""), 10);
|
|
331
365
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
package/bin/weclawbotctl.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const DEFAULT_OPENCLAW_PLUGIN_SPEC = "@openbrt/weclawbotctl";
|
|
|
17
17
|
const MIN_OPENCLAW_VERSION = "2026.6.9";
|
|
18
18
|
|
|
19
19
|
const [command, ...args] = process.argv.slice(2);
|
|
20
|
-
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "openclaw"]);
|
|
20
|
+
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "clear", "openclaw"]);
|
|
21
21
|
if (!commands.has(command)) {
|
|
22
22
|
usage();
|
|
23
23
|
process.exit(64);
|
|
@@ -30,6 +30,7 @@ try {
|
|
|
30
30
|
else if (command === "export") await commandExport(args);
|
|
31
31
|
else if (command === "unbind") await commandUnbind(args);
|
|
32
32
|
else if (command === "screen") await commandScreen(args);
|
|
33
|
+
else if (command === "clear") await commandClear(args);
|
|
33
34
|
else if (command === "openclaw") await commandOpenClaw(args);
|
|
34
35
|
else await commandActivity(command, args);
|
|
35
36
|
} catch (error) {
|
|
@@ -245,6 +246,8 @@ async function commandScreen(values) {
|
|
|
245
246
|
id: document.id,
|
|
246
247
|
pages: document.pages.length,
|
|
247
248
|
force_replace: document.force_replace === true,
|
|
249
|
+
warnings: validation.warnings,
|
|
250
|
+
layout_guidance: validation.layout_guidance,
|
|
248
251
|
status: delivery.status,
|
|
249
252
|
}));
|
|
250
253
|
return;
|
|
@@ -257,9 +260,50 @@ async function commandScreen(values) {
|
|
|
257
260
|
id: document.id,
|
|
258
261
|
pages: document.pages.length,
|
|
259
262
|
force_replace: document.force_replace === true,
|
|
263
|
+
warnings: validation.warnings,
|
|
264
|
+
layout_guidance: validation.layout_guidance,
|
|
260
265
|
}));
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
async function commandClear(values) {
|
|
269
|
+
const options = parseOptions(values, {
|
|
270
|
+
credentials: credentialsPath(),
|
|
271
|
+
target: "note",
|
|
272
|
+
wait: true,
|
|
273
|
+
timeout: 12,
|
|
274
|
+
});
|
|
275
|
+
if (options._.length > 1) {
|
|
276
|
+
throw new Error("Usage: weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]");
|
|
277
|
+
}
|
|
278
|
+
const target = normalizeClearTarget(options._[0] || options.target);
|
|
279
|
+
const control = {
|
|
280
|
+
schema: "weclawbot.control.v1",
|
|
281
|
+
id: `clear_${crypto.randomUUID()}`,
|
|
282
|
+
kind: "screen_clear",
|
|
283
|
+
target,
|
|
284
|
+
};
|
|
285
|
+
const credentials = await requireCredentials(expandPath(options.credentials));
|
|
286
|
+
if (options.wait) {
|
|
287
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
288
|
+
expectedDetail: clearStatusDetail(target),
|
|
289
|
+
timeoutMs: Math.max(1, Number(options.timeout) || 12) * 1000,
|
|
290
|
+
});
|
|
291
|
+
if (delivery.status.kind !== "applied") {
|
|
292
|
+
throw new Error(`Device rejected screen clear: ${delivery.status.detail || "unknown"}`);
|
|
293
|
+
}
|
|
294
|
+
console.log(JSON.stringify({
|
|
295
|
+
ok: true,
|
|
296
|
+
published: true,
|
|
297
|
+
applied: true,
|
|
298
|
+
target,
|
|
299
|
+
status: delivery.status,
|
|
300
|
+
}));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await publishControl(credentials, control);
|
|
304
|
+
console.log(JSON.stringify({ ok: true, published: true, applied: null, target }));
|
|
305
|
+
}
|
|
306
|
+
|
|
263
307
|
async function commandActivity(state, values) {
|
|
264
308
|
const options = parseOptions(values, {
|
|
265
309
|
credentials: credentialsPath(),
|
|
@@ -317,6 +361,13 @@ async function commandOpenClawInstall(values) {
|
|
|
317
361
|
if (options.force) installArgs.push("--force");
|
|
318
362
|
await runRequired(openclaw, installArgs);
|
|
319
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
|
+
]);
|
|
320
371
|
console.log("OpenClaw plugin installed and enabled. Restart the OpenClaw gateway or app so it reloads plugins.");
|
|
321
372
|
if (options.doctor) {
|
|
322
373
|
await commandOpenClawDoctor(["--bin", openclaw, "--gateway=false"]);
|
|
@@ -358,6 +409,22 @@ async function commandOpenClawDoctor(values) {
|
|
|
358
409
|
hint: weclawbotIssue ? "Upgrade and reinstall: weclawbotctl openclaw install" : "",
|
|
359
410
|
});
|
|
360
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
|
+
|
|
361
428
|
if (options.gateway) {
|
|
362
429
|
const gatewayEnv = { ...process.env };
|
|
363
430
|
const defaultCert = path.join(os.homedir(), ".openclaw", "gateway", "tls", "gateway-cert.pem");
|
|
@@ -643,6 +710,18 @@ function compactText(value) {
|
|
|
643
710
|
return String(value || "").replace(/\s+/gu, " ").trim().slice(0, 500);
|
|
644
711
|
}
|
|
645
712
|
|
|
713
|
+
function normalizeClearTarget(value) {
|
|
714
|
+
const target = String(value || "note").trim();
|
|
715
|
+
if (target === "note" || target === "idle_photo" || target === "photo") {
|
|
716
|
+
return target === "photo" ? "idle_photo" : target;
|
|
717
|
+
}
|
|
718
|
+
throw new Error("clear target must be note or idle_photo");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function clearStatusDetail(target) {
|
|
722
|
+
return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
|
|
723
|
+
}
|
|
724
|
+
|
|
646
725
|
function shellValue(value) {
|
|
647
726
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
648
727
|
}
|
|
@@ -661,6 +740,7 @@ function usage() {
|
|
|
661
740
|
weclawbotctl thinking [--ttl seconds] [--id correlation-id]
|
|
662
741
|
weclawbotctl idle [--id correlation-id]
|
|
663
742
|
weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]
|
|
743
|
+
weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
|
|
664
744
|
weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
|
|
665
745
|
weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
|
|
666
746
|
}
|
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",
|
|
@@ -59,6 +66,43 @@ export default defineToolPlugin({
|
|
|
59
66
|
}, { additionalProperties: false }),
|
|
60
67
|
execute: ({ document, device_context }) => validateScreenDocument(document, device_context),
|
|
61
68
|
}),
|
|
69
|
+
tool({
|
|
70
|
+
name: "weclawbot_clear_screen",
|
|
71
|
+
label: "Clear WeClawBot screen",
|
|
72
|
+
description: "Clear the paired WeClawBot note or idle-photo state with the firmware screen_clear control. Do not emulate clearing by publishing a blank or black bitmap.",
|
|
73
|
+
parameters: Type.Object({
|
|
74
|
+
target: Type.Optional(Type.String()),
|
|
75
|
+
credentials_path: Type.Optional(Type.String()),
|
|
76
|
+
wait_status: Type.Optional(Type.Boolean()),
|
|
77
|
+
timeout_seconds: Type.Optional(Type.Number()),
|
|
78
|
+
}, { additionalProperties: false }),
|
|
79
|
+
execute: async ({ target, credentials_path, wait_status, timeout_seconds }) => {
|
|
80
|
+
const clearTarget = normalizeClearTarget(target);
|
|
81
|
+
const control = {
|
|
82
|
+
schema: "weclawbot.control.v1",
|
|
83
|
+
id: `clear_${crypto.randomUUID()}`,
|
|
84
|
+
kind: "screen_clear",
|
|
85
|
+
target: clearTarget,
|
|
86
|
+
};
|
|
87
|
+
const credentials = await requireCredentials(credentials_path);
|
|
88
|
+
if (wait_status !== false) {
|
|
89
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
90
|
+
expectedDetail: clearStatusDetail(clearTarget),
|
|
91
|
+
timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
ok: delivery.status.kind === "applied",
|
|
95
|
+
published: true,
|
|
96
|
+
applied: delivery.status.kind === "applied",
|
|
97
|
+
rejected: delivery.status.kind === "rejected",
|
|
98
|
+
target: clearTarget,
|
|
99
|
+
status: delivery.status,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
await publishControl(credentials, control);
|
|
103
|
+
return { ok: true, published: true, applied: null, target: clearTarget };
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
62
106
|
tool({
|
|
63
107
|
name: "weclawbot_publish_screen_document",
|
|
64
108
|
label: "Publish WeClawBot screen document",
|
|
@@ -90,6 +134,8 @@ export default defineToolPlugin({
|
|
|
90
134
|
document: outbound,
|
|
91
135
|
};
|
|
92
136
|
const credentials = await requireCredentials(credentials_path);
|
|
137
|
+
const previewPages = renderScreenDocumentPreviewPages(outbound);
|
|
138
|
+
const preview = previewSummary(previewPages);
|
|
93
139
|
if (wait_status !== false) {
|
|
94
140
|
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
95
141
|
expectedDetail: outbound.id,
|
|
@@ -103,6 +149,9 @@ export default defineToolPlugin({
|
|
|
103
149
|
id: outbound.id,
|
|
104
150
|
pages: outbound.pages.length,
|
|
105
151
|
force_replace: outbound.force_replace === true,
|
|
152
|
+
warnings: validation.warnings,
|
|
153
|
+
layout_guidance: validation.layout_guidance,
|
|
154
|
+
preview,
|
|
106
155
|
status: delivery.status,
|
|
107
156
|
};
|
|
108
157
|
}
|
|
@@ -114,6 +163,9 @@ export default defineToolPlugin({
|
|
|
114
163
|
id: outbound.id,
|
|
115
164
|
pages: outbound.pages.length,
|
|
116
165
|
force_replace: outbound.force_replace === true,
|
|
166
|
+
warnings: validation.warnings,
|
|
167
|
+
layout_guidance: validation.layout_guidance,
|
|
168
|
+
preview,
|
|
117
169
|
};
|
|
118
170
|
},
|
|
119
171
|
}),
|
|
@@ -160,6 +212,218 @@ export default defineToolPlugin({
|
|
|
160
212
|
],
|
|
161
213
|
});
|
|
162
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
|
+
|
|
163
427
|
async function requireCredentials(credentialsPath) {
|
|
164
428
|
const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
|
|
165
429
|
const payload = await readCredentials(file);
|
|
@@ -181,11 +445,47 @@ function expandPath(value) {
|
|
|
181
445
|
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
182
446
|
}
|
|
183
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
|
+
|
|
184
453
|
function cloneObject(value) {
|
|
185
454
|
if (!value || typeof value !== "object") throw new Error("document must be an object");
|
|
186
455
|
return JSON.parse(JSON.stringify(value));
|
|
187
456
|
}
|
|
188
457
|
|
|
458
|
+
function normalizeClearTarget(value) {
|
|
459
|
+
const target = String(value || "note").trim();
|
|
460
|
+
if (target === "note" || target === "idle_photo" || target === "photo") {
|
|
461
|
+
return target === "photo" ? "idle_photo" : target;
|
|
462
|
+
}
|
|
463
|
+
throw new Error("clear target must be note or idle_photo");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function clearStatusDetail(target) {
|
|
467
|
+
return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
|
|
468
|
+
}
|
|
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
|
+
|
|
189
489
|
function maskedMqtt(config, topics) {
|
|
190
490
|
return {
|
|
191
491
|
url: config.url,
|
package/lib/direct-control.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export function resolveDeviceContext(value) {
|
|
|
29
29
|
export function validateScreenDocument(value, suppliedContext) {
|
|
30
30
|
const context = resolveDeviceContext(suppliedContext);
|
|
31
31
|
const errors = [];
|
|
32
|
+
const pageStats = [];
|
|
32
33
|
const document = value && typeof value === "object" ? value : null;
|
|
33
34
|
const viewport = context.content_viewport;
|
|
34
35
|
if (!document) return result(context, errors.concat("document must be an object"));
|
|
@@ -48,12 +49,15 @@ export function validateScreenDocument(value, suppliedContext) {
|
|
|
48
49
|
if (!Array.isArray(document.pages) || document.pages.length < 1 || document.pages.length > viewport.max_pages) {
|
|
49
50
|
errors.push(`pages must contain 1..${viewport.max_pages} items`);
|
|
50
51
|
} else {
|
|
51
|
-
document.pages.forEach((page, index) => validatePage(page, index, viewport, errors));
|
|
52
|
+
document.pages.forEach((page, index) => validatePage(page, index, viewport, errors, pageStats));
|
|
53
|
+
if (pageStats.length > 0 && pageStats.every((stat) => stat.uniform)) {
|
|
54
|
+
errors.push("uniform_screen_document: content pages cannot all be a single color; use screen_clear for clearing");
|
|
55
|
+
}
|
|
52
56
|
}
|
|
53
|
-
return result(context, errors);
|
|
57
|
+
return result(context, errors, document);
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
function validatePage(page, index, viewport, errors) {
|
|
60
|
+
function validatePage(page, index, viewport, errors, pageStats) {
|
|
57
61
|
if (!page || typeof page !== "object") {
|
|
58
62
|
errors.push(`pages[${index}] must be an object`);
|
|
59
63
|
return;
|
|
@@ -77,14 +81,35 @@ function validatePage(page, index, viewport, errors) {
|
|
|
77
81
|
errors.push(`pages[${index}].data_b64 is not valid base64`);
|
|
78
82
|
} else if (Number.isInteger(stride) && Number.isInteger(height) && bytes.length !== stride * height) {
|
|
79
83
|
errors.push(`pages[${index}].data_b64 byte length does not match stride * height`);
|
|
84
|
+
} else if (bytes.length > 0) {
|
|
85
|
+
pageStats.push({ index, uniform: bytes.every((byte) => byte === bytes[0]) });
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
function result(context, errors) {
|
|
89
|
+
function result(context, errors, document = null) {
|
|
90
|
+
const pageCount = Array.isArray(document?.pages) ? document.pages.length : 0;
|
|
91
|
+
const warnings = [];
|
|
92
|
+
if (pageCount === 1) {
|
|
93
|
+
warnings.push("single_page_document: firmware will not split pixels; verify the page remains readable before publishing");
|
|
94
|
+
} else if (pageCount > 1) {
|
|
95
|
+
warnings.push("multi_page_document: firmware will auto-flip pages and manual left/right buttons can change pages");
|
|
96
|
+
}
|
|
84
97
|
return {
|
|
85
98
|
ok: errors.length === 0,
|
|
86
99
|
errors,
|
|
100
|
+
warnings,
|
|
87
101
|
viewport: context.content_viewport,
|
|
102
|
+
layout_guidance: {
|
|
103
|
+
hardware_contract: "agent supplies pre-rendered mono1 pixels; firmware validates geometry and does not lay out text or split pages",
|
|
104
|
+
page_contract: "pages.length is the physical page count; content documents support 1-3 pages",
|
|
105
|
+
preference_policy: "preserve user-agent learned layout preferences unless they violate hardware bounds or the user asks to change them",
|
|
106
|
+
review_policy: "agent should inspect rendered bitmap pages against user preferences and learned standards before publishing when possible",
|
|
107
|
+
max_pages: context.content_viewport?.max_pages,
|
|
108
|
+
content_viewport_px: {
|
|
109
|
+
width: context.content_viewport?.width,
|
|
110
|
+
height: context.content_viewport?.height,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
88
113
|
agent_transport: context.agent_transport || null,
|
|
89
114
|
direct_delivery_ready: context.agent_transport?.screen_document_available === true
|
|
90
115
|
|| context.agent_transport?.available === true,
|
|
@@ -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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"tools": [
|
|
7
7
|
"weclawbot_status",
|
|
8
8
|
"weclawbot_validate_screen_document",
|
|
9
|
+
"weclawbot_clear_screen",
|
|
9
10
|
"weclawbot_publish_screen_document",
|
|
10
11
|
"weclawbot_validate_activity",
|
|
11
12
|
"weclawbot_publish_activity"
|
|
@@ -20,6 +21,15 @@
|
|
|
20
21
|
"configSchema": {
|
|
21
22
|
"type": "object",
|
|
22
23
|
"additionalProperties": false,
|
|
23
|
-
"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
|
+
}
|
|
24
34
|
}
|
|
25
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,41 @@ 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.
|
|
63
|
+
|
|
64
|
+
For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
|
|
65
|
+
current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
|
|
66
|
+
simulate clearing by publishing a blank, white, or black `screen_document`; that
|
|
67
|
+
creates a new note instead of clearing firmware state.
|
|
68
|
+
|
|
69
|
+
## Layout, preferences, and page splitting
|
|
70
|
+
|
|
71
|
+
Treat this skill as a hardware contract and starting point, not as a fixed house
|
|
72
|
+
style. If the user and agent have already developed layout preferences, visual
|
|
73
|
+
language, page rhythm, font choices, or review habits, preserve those choices
|
|
74
|
+
unless the user asks to change them or they violate the device bounds below.
|
|
75
|
+
Skill upgrades must be additive and compatible with accumulated user-agent
|
|
76
|
+
practice; do not reset or overwrite local style memory just because this package
|
|
77
|
+
changed.
|
|
78
|
+
|
|
79
|
+
Hardware facts: the content viewport is 368 x 206 mono1 pixels, and a document
|
|
80
|
+
may contain one to three content pages. The firmware will not split a single
|
|
81
|
+
pixel page after receiving it; if the document has `pages.length === 1`, the
|
|
82
|
+
physical screen has exactly one page. Multi-page documents can be auto-flipped by
|
|
83
|
+
firmware and changed with the physical left/right buttons.
|
|
84
|
+
|
|
85
|
+
Before publishing, review the actual rendered bitmap pages when your runtime can
|
|
86
|
+
inspect images. Judge the preview against the user's preferences and the agent's
|
|
87
|
+
own learned standards: legibility, margins, crowding, page count, and continuity
|
|
88
|
+
across pages. If the preview does not satisfy those standards, regenerate the
|
|
89
|
+
pages before publishing. This review loop belongs in the agent/tool layer; do not
|
|
90
|
+
expect firmware to fix typography or split pages after the pixels arrive.
|
|
61
91
|
|
|
62
92
|
`weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
|
|
63
93
|
device status topic by default. Treat only `applied` as success. If the device
|
|
@@ -92,9 +122,11 @@ For a paired device, publish `weclawbot.activity.v1` with `state: "thinking"`
|
|
|
92
122
|
immediately before an LLM call, long retrieval, or multi-step operation, then
|
|
93
123
|
always publish `state: "idle"` in a `finally` path after success or failure.
|
|
94
124
|
The thinking message requires a 5-120 second `ttl_seconds` and a stable
|
|
95
|
-
`correlation_id`;
|
|
96
|
-
|
|
97
|
-
|
|
125
|
+
`correlation_id`; the matching idle message must reuse the same id. Newer
|
|
126
|
+
firmware rejects stale or unrelated idle messages and keeps the active thinking
|
|
127
|
+
state. Use `weclawbot_validate_activity` first. It is a temporary overlay that
|
|
128
|
+
restores the exact prior page, not a screen document and not a status to leave on
|
|
129
|
+
indefinitely. Do not publish it when
|
|
98
130
|
`agent_transport.available` is false.
|
|
99
131
|
|
|
100
132
|
Return this shape:
|
|
@@ -7,6 +7,7 @@ Wants=network-online.target
|
|
|
7
7
|
Type=simple
|
|
8
8
|
EnvironmentFile=%h/.config/weclawbot/openclaw-curator.env
|
|
9
9
|
Environment=NODE_EXTRA_CA_CERTS=%h/.openclaw/gateway/tls/gateway-cert.pem
|
|
10
|
+
Environment=WEC_WECLAWBOTCTL_BIN=%h/.npm-global/bin/weclawbotctl
|
|
10
11
|
ExecStart=%h/.npm-global/bin/weclawbot-openclaw-bridge
|
|
11
12
|
Restart=always
|
|
12
13
|
RestartSec=3
|
|
@@ -5,6 +5,7 @@ import { resolveDeviceContext, validateScreenDocument } from "../lib/direct-cont
|
|
|
5
5
|
const context = resolveDeviceContext();
|
|
6
6
|
const viewport = context.content_viewport;
|
|
7
7
|
const bytes = Buffer.alloc(Math.ceil(viewport.width / 8) * viewport.height, 0xff);
|
|
8
|
+
bytes[0] = 0x00;
|
|
8
9
|
const valid = validateScreenDocument({
|
|
9
10
|
schema: "weclawbot.screen_document.v1",
|
|
10
11
|
id: "test-card",
|
|
@@ -33,4 +34,22 @@ const invalid = validateScreenDocument({
|
|
|
33
34
|
assert.equal(invalid.ok, false);
|
|
34
35
|
assert.ok(invalid.errors.some((error) => error.includes("width")));
|
|
35
36
|
|
|
37
|
+
const uniform = validateScreenDocument({
|
|
38
|
+
schema: "weclawbot.screen_document.v1",
|
|
39
|
+
id: "bad-clear",
|
|
40
|
+
base_revision: "",
|
|
41
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
42
|
+
target: "content",
|
|
43
|
+
kind: "replace",
|
|
44
|
+
pages: [{
|
|
45
|
+
format: "mono1",
|
|
46
|
+
width: viewport.width,
|
|
47
|
+
height: viewport.height,
|
|
48
|
+
stride: Math.ceil(viewport.width / 8),
|
|
49
|
+
data_b64: Buffer.alloc(Math.ceil(viewport.width / 8) * viewport.height, 0x00).toString("base64"),
|
|
50
|
+
}],
|
|
51
|
+
}, context);
|
|
52
|
+
assert.equal(uniform.ok, false);
|
|
53
|
+
assert.ok(uniform.errors.some((error) => error.includes("uniform_screen_document")));
|
|
54
|
+
|
|
36
55
|
console.log("direct-control validator: ok");
|
|
@@ -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,17 @@ 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.
|
|
53
|
+
|
|
54
|
+
The content viewport is 368 x 206 mono1 pixels, with one to three content pages.
|
|
55
|
+
The firmware will not split a single pixel page after receiving it; `pages.length`
|
|
56
|
+
is the page count on the physical screen. Preserve user-agent layout preferences,
|
|
57
|
+
visual language, and review habits across plugin upgrades unless they violate
|
|
58
|
+
the hardware limits or the user asks to change them.
|