@clawling/clawchat-plugin-openclaw 2026.9.23-2 → 2026.9.26-2
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/dist/src/api-client.js +58 -6
- package/dist/src/liveware-icon.js +113 -0
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools-schema.js +6 -0
- package/dist/src/tools.js +19 -3
- package/package.json +1 -1
- package/skills/clawchat-liveware/SKILL.md +9 -1
- package/skills/clawchat-orchestration/SKILL.md +2 -2
- package/skills/manifest.json +10 -10
- package/src/api-client.ts +70 -8
- package/src/api-types.ts +26 -1
- package/src/liveware-icon.ts +122 -0
- package/src/skill-update.ts +1 -1
- package/src/tools-schema.ts +12 -0
- package/src/tools.ts +20 -3
package/dist/src/api-client.js
CHANGED
|
@@ -346,6 +346,30 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
346
346
|
}
|
|
347
347
|
return obj;
|
|
348
348
|
}
|
|
349
|
+
function isLivewareView(v) {
|
|
350
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
351
|
+
return false;
|
|
352
|
+
const o = v;
|
|
353
|
+
const optionalString = (x) => x === undefined || typeof x === "string";
|
|
354
|
+
return (typeof o.id === "string" &&
|
|
355
|
+
typeof o.liveware_id === "string" &&
|
|
356
|
+
o.liveware_id !== "" &&
|
|
357
|
+
typeof o.name === "string" &&
|
|
358
|
+
typeof o.url === "string" &&
|
|
359
|
+
optionalString(o.subtitle) &&
|
|
360
|
+
optionalString(o.icon_url));
|
|
361
|
+
}
|
|
362
|
+
function livewareToAppView(v) {
|
|
363
|
+
return {
|
|
364
|
+
id: v.id,
|
|
365
|
+
app_id: v.liveware_id,
|
|
366
|
+
liveware_id: v.liveware_id,
|
|
367
|
+
name: v.name,
|
|
368
|
+
subtitle: v.subtitle ?? "",
|
|
369
|
+
icon_url: v.icon_url ?? "",
|
|
370
|
+
url: v.url,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
349
373
|
function assertNonBlankId(value, label) {
|
|
350
374
|
if (!value.trim()) {
|
|
351
375
|
throw new ClawlingApiError("validation", `${label} is required`);
|
|
@@ -475,17 +499,45 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
475
499
|
},
|
|
476
500
|
async registerApp(params) {
|
|
477
501
|
assertNonBlankId(params.appId, "registerApp: appId");
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
502
|
+
// Multipart even without an icon: the liveware route reads form fields.
|
|
503
|
+
const fd = new FormData();
|
|
504
|
+
fd.set("name", params.name);
|
|
505
|
+
fd.set("liveware_id", params.appId);
|
|
506
|
+
fd.set("url", params.url);
|
|
507
|
+
// The server only replaces a subtitle when the new one is non-empty, so
|
|
508
|
+
// an empty value cannot clear it; do not send one.
|
|
509
|
+
const subtitle = params.subtitle?.trim();
|
|
510
|
+
if (subtitle)
|
|
511
|
+
fd.set("subtitle", subtitle);
|
|
512
|
+
if (params.icon) {
|
|
513
|
+
const file = new File([new Uint8Array(params.icon.buffer)], params.icon.filename, {
|
|
514
|
+
type: params.icon.mime,
|
|
515
|
+
});
|
|
516
|
+
fd.set("icon", file);
|
|
517
|
+
}
|
|
518
|
+
const data = await call("POST", "/v1/agents/me/liveware", { body: fd });
|
|
519
|
+
if (!isLivewareView(data?.liveware)) {
|
|
520
|
+
throw new ClawlingApiError("transport", "invalid liveware response: missing liveware entry", {
|
|
521
|
+
path: "/v1/agents/me/liveware",
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return { app: livewareToAppView(data.liveware) };
|
|
482
525
|
},
|
|
483
526
|
async listApps() {
|
|
484
|
-
|
|
527
|
+
const data = await call("GET", "/v1/agents/me/liveware");
|
|
528
|
+
// A malformed body must not read as "no apps": the liveware sample
|
|
529
|
+
// bootstrap would take that as a fresh account and register a duplicate.
|
|
530
|
+
const list = data?.liveware;
|
|
531
|
+
if (!Array.isArray(list) || !list.every(isLivewareView)) {
|
|
532
|
+
throw new ClawlingApiError("transport", "invalid liveware response: malformed liveware list", {
|
|
533
|
+
path: "/v1/agents/me/liveware",
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
return { apps: list.map(livewareToAppView) };
|
|
485
537
|
},
|
|
486
538
|
async unregisterApp(appId) {
|
|
487
539
|
assertNonBlankId(appId, "unregisterApp: appId");
|
|
488
|
-
return await call("DELETE", `/v1/agents/me/
|
|
540
|
+
return await call("DELETE", `/v1/agents/me/liveware/${encodeURIComponent(appId)}`);
|
|
489
541
|
},
|
|
490
542
|
async searchUsers(params) {
|
|
491
543
|
const sp = new URLSearchParams();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Server-side cap on the WHOLE registration request (default configuration). */
|
|
4
|
+
export const LIVEWARE_REQUEST_MAX_BYTES = 25 * 1024 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Fixed reserve for the multipart envelope (boundaries and part headers).
|
|
7
|
+
* The text fields' own UTF-8 bytes are subtracted on top of this, so an icon
|
|
8
|
+
* that passes local validation also fits the server's request cap. Keep in
|
|
9
|
+
* sync with the Hermes plugin.
|
|
10
|
+
*/
|
|
11
|
+
export const LIVEWARE_MULTIPART_OVERHEAD_BYTES = 64 * 1024;
|
|
12
|
+
export const LIVEWARE_SUBTITLE_MAX_CHARS = 200;
|
|
13
|
+
/** Largest icon that fits in one request next to text fields of `textFieldBytes`. */
|
|
14
|
+
export function livewareIconMaxBytes(textFieldBytes) {
|
|
15
|
+
return LIVEWARE_REQUEST_MAX_BYTES - LIVEWARE_MULTIPART_OVERHEAD_BYTES - textFieldBytes;
|
|
16
|
+
}
|
|
17
|
+
/** UTF-8 byte length of the multipart text fields (and icon filename). */
|
|
18
|
+
export function livewareTextFieldBytes(values) {
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (const v of values)
|
|
21
|
+
if (v)
|
|
22
|
+
n += Buffer.byteLength(v, "utf8");
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Normalise a subtitle the way it is sent: trimmed, one line, at most 200
|
|
27
|
+
* characters. An omitted, empty or blank subtitle becomes `undefined` and is
|
|
28
|
+
* not sent. The server then keeps the existing subtitle, because it only
|
|
29
|
+
* replaces a subtitle when the new one is non-empty.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeLivewareSubtitle(raw) {
|
|
32
|
+
if (raw === undefined || raw === null)
|
|
33
|
+
return { ok: true, value: undefined };
|
|
34
|
+
if (typeof raw !== "string")
|
|
35
|
+
return { ok: false, message: "subtitle must be a string" };
|
|
36
|
+
// Line breaks are checked on the raw value: trimming would hide a trailing
|
|
37
|
+
// "\n" and let a caller believe a multi-line value was accepted.
|
|
38
|
+
if (/[\r\n]/.test(raw))
|
|
39
|
+
return { ok: false, message: "subtitle must be one line (no line breaks)" };
|
|
40
|
+
const value = raw.trim();
|
|
41
|
+
if (!value)
|
|
42
|
+
return { ok: true, value: undefined };
|
|
43
|
+
if ([...value].length > LIVEWARE_SUBTITLE_MAX_CHARS) {
|
|
44
|
+
return { ok: false, message: "subtitle must be at most 200 characters" };
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, value };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Detect the icon type from its leading bytes, mirroring the server, which
|
|
50
|
+
* sniffs the content and ignores the declared type and file extension.
|
|
51
|
+
*/
|
|
52
|
+
export function sniffLivewareIconMime(head) {
|
|
53
|
+
const b = head;
|
|
54
|
+
if (b.length >= 8 &&
|
|
55
|
+
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
56
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {
|
|
57
|
+
return "image/png";
|
|
58
|
+
}
|
|
59
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
60
|
+
return "image/jpeg";
|
|
61
|
+
}
|
|
62
|
+
const ascii = (from, to) => String.fromCharCode(...b.subarray(from, to));
|
|
63
|
+
if (b.length >= 14 && ascii(0, 4) === "RIFF" && ascii(8, 14) === "WEBPVP") {
|
|
64
|
+
return "image/webp";
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function errMessage(err) {
|
|
69
|
+
return err instanceof Error ? err.message : String(err);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read and validate a local liveware icon file. Returns either the upload
|
|
73
|
+
* part or a human-readable validation message; filesystem errors never throw.
|
|
74
|
+
* `textFieldBytes` is the UTF-8 size of the other multipart fields, used to
|
|
75
|
+
* keep the whole request under the server cap.
|
|
76
|
+
*/
|
|
77
|
+
export function readLivewareIcon(iconPath, textFieldBytes = 0) {
|
|
78
|
+
if (!iconPath || !path.isAbsolute(iconPath)) {
|
|
79
|
+
return { ok: false, message: "iconPath must be an absolute local path" };
|
|
80
|
+
}
|
|
81
|
+
let stat;
|
|
82
|
+
try {
|
|
83
|
+
stat = fs.statSync(iconPath);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return { ok: false, message: `cannot stat ${iconPath}: ${errMessage(err)}` };
|
|
87
|
+
}
|
|
88
|
+
if (!stat.isFile()) {
|
|
89
|
+
return { ok: false, message: `${iconPath} is not a regular file` };
|
|
90
|
+
}
|
|
91
|
+
const filename = path.basename(iconPath);
|
|
92
|
+
const max = livewareIconMaxBytes(textFieldBytes + Buffer.byteLength(filename, "utf8"));
|
|
93
|
+
const tooLarge = (size) => `icon too large (${size} bytes; max ${max} bytes for this request: the 25MB request limit minus multipart overhead)`;
|
|
94
|
+
if (stat.size > max) {
|
|
95
|
+
return { ok: false, message: tooLarge(stat.size) };
|
|
96
|
+
}
|
|
97
|
+
let buffer;
|
|
98
|
+
try {
|
|
99
|
+
buffer = fs.readFileSync(iconPath);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { ok: false, message: `cannot read ${iconPath}: ${errMessage(err)}` };
|
|
103
|
+
}
|
|
104
|
+
// The file may have grown between stat and read.
|
|
105
|
+
if (buffer.length > max) {
|
|
106
|
+
return { ok: false, message: tooLarge(buffer.length) };
|
|
107
|
+
}
|
|
108
|
+
const mime = sniffLivewareIconMime(buffer.subarray(0, 16));
|
|
109
|
+
if (!mime) {
|
|
110
|
+
return { ok: false, message: `icon must be a PNG, JPEG or WebP image (checked from the file's bytes): ${iconPath}` };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, icon: { buffer, filename, mime } };
|
|
113
|
+
}
|
package/dist/src/skill-update.js
CHANGED
|
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
|
|
|
66
66
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
67
67
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
68
68
|
*/
|
|
69
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.15.
|
|
69
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.15.2";
|
|
70
70
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
71
71
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
|
72
72
|
/** This adapter's host target inside `skills/manifest.json`. */
|
package/dist/src/tools-schema.js
CHANGED
|
@@ -278,6 +278,12 @@ export const ClawchatRegisterAppSchema = Type.Object({
|
|
|
278
278
|
name: Type.String({ minLength: 1, description: "Human-readable app name shown on the launcher tile." }),
|
|
279
279
|
appId: Type.String({ minLength: 1, description: "The liveware app id from `liveware app create`/`liveware app list`." }),
|
|
280
280
|
url: Type.String({ minLength: 1, description: "Public tunnel URL from `liveware tunnel bind` (http/https)." }),
|
|
281
|
+
subtitle: Type.Optional(Type.String({
|
|
282
|
+
description: "Optional one-line subtitle for the tile (one line, max 200 characters, surrounding spaces trimmed). An omitted or empty subtitle keeps the current one: re-registering cannot clear a subtitle.",
|
|
283
|
+
})),
|
|
284
|
+
iconPath: Type.Optional(Type.String({
|
|
285
|
+
description: "Optional absolute local path of the tile icon: a PNG, JPEG or WebP image, under 25MB (the whole request is capped at 25MB). Omit to keep the current icon on re-registration.",
|
|
286
|
+
})),
|
|
281
287
|
});
|
|
282
288
|
export const ClawchatListAppsSchema = Type.Object({});
|
|
283
289
|
export const ClawchatUnregisterAppSchema = Type.Object({
|
package/dist/src/tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
5
5
|
import { resolveLivewarePath } from "./liveware-cli.js";
|
|
6
|
+
import { livewareTextFieldBytes, normalizeLivewareSubtitle, readLivewareIcon } from "./liveware-icon.js";
|
|
6
7
|
import { ClawlingApiError, } from "./api-types.js";
|
|
7
8
|
import { mapGateOutcome } from "./gate-outcome.js";
|
|
8
9
|
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
@@ -1295,12 +1296,27 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1295
1296
|
name: "clawchat_register_app",
|
|
1296
1297
|
label: "Register ClawChat App",
|
|
1297
1298
|
description: toolDescription("Register a liveware-tunneled web app to ClawChat so it appears in the owner's chat with this agent. " +
|
|
1298
|
-
"Call AFTER `liveware tunnel bind` returns a public URL. Params: name, appId (liveware app id), url (public URL)
|
|
1299
|
+
"Call AFTER `liveware tunnel bind` returns a public URL. Params: name, appId (liveware app id), url (public URL), " +
|
|
1300
|
+
"optional subtitle (one line, max 200 characters) and optional iconPath (absolute local PNG/JPEG/WebP file, under 25MB). " +
|
|
1301
|
+
"Registering the same appId again updates that tile: name and url are replaced; subtitle and icon only when given " +
|
|
1302
|
+
"(an empty subtitle keeps the current one, so re-registering cannot clear a subtitle)."),
|
|
1299
1303
|
parameters: ClawchatRegisterAppSchema,
|
|
1300
1304
|
async execute(_callId, params) {
|
|
1301
1305
|
return await recordClawchatToolCall(accountId, "clawchat_register_app", params, async () => {
|
|
1302
1306
|
const p = params;
|
|
1303
|
-
|
|
1307
|
+
const sub = normalizeLivewareSubtitle(p.subtitle);
|
|
1308
|
+
if (!sub.ok)
|
|
1309
|
+
return validationError(`clawchat-plugin-openclaw: ${sub.message}`);
|
|
1310
|
+
const subtitle = sub.value;
|
|
1311
|
+
let icon;
|
|
1312
|
+
if (p.iconPath !== undefined && p.iconPath !== "") {
|
|
1313
|
+
const textBytes = livewareTextFieldBytes([p.name, p.appId, p.url, subtitle]);
|
|
1314
|
+
const read = readLivewareIcon(String(p.iconPath), textBytes);
|
|
1315
|
+
if (!read.ok)
|
|
1316
|
+
return validationError(`clawchat-plugin-openclaw: ${read.message}`);
|
|
1317
|
+
icon = read.icon;
|
|
1318
|
+
}
|
|
1319
|
+
return await withClient(accountId, (c) => c.registerApp({ name: p.name, appId: p.appId, url: p.url, subtitle, icon }));
|
|
1304
1320
|
});
|
|
1305
1321
|
},
|
|
1306
1322
|
};
|
|
@@ -1571,7 +1587,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1571
1587
|
return {
|
|
1572
1588
|
name: "clawchat_orchestrate_create_connect_code",
|
|
1573
1589
|
label: "Issue A Connect Code",
|
|
1574
|
-
description: toolDescription("Mint a connect code on the owner's behalf, valid
|
|
1590
|
+
description: toolDescription("Mint a connect code on the owner's behalf, valid 45 minutes. " +
|
|
1575
1591
|
"TRIGGER — invoke when the owner asks for a code to connect a new agent. " +
|
|
1576
1592
|
"The rate limit bucket belongs to the owner and is shared with their own manual issuance, so do not retry on a limit."),
|
|
1577
1593
|
parameters: ClawchatOrchCreateConnectCodeSchema,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawchat-liveware
|
|
3
|
-
version: 1.2.
|
|
3
|
+
version: 1.2.3
|
|
4
4
|
description: Use when the user wants to expose this agent's local web service to the public internet via the liveware CLI and make it appear as an app in their ClawChat chat with this agent. Covers logging in to liveware with the ClawChat account, creating a liveware app, binding a tunnel to a local port, registering the public URL to ClawChat, restricting who may open each app, and fully unregistering and deleting an app.
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -78,6 +78,14 @@ ClawChat so it shows as an app tile in the owner's chat with this agent.
|
|
|
78
78
|
7. **Register to ClawChat** so it appears in the owner's chat — call the tool, do NOT
|
|
79
79
|
curl the API directly:
|
|
80
80
|
`clawchat_register_app(name="<app name>", appId="<app id>", url="<public URL>")`
|
|
81
|
+
Two optional arguments decorate the tile:
|
|
82
|
+
- `subtitle="<text>"`: one line, no line breaks, at most 200 characters after
|
|
83
|
+
surrounding spaces are trimmed. An omitted or empty subtitle keeps the current one:
|
|
84
|
+
re-registering the same app id cannot clear a subtitle, so do not promise the user
|
|
85
|
+
that it can.
|
|
86
|
+
- `iconPath="<absolute local path>"`: a PNG, JPEG, or WebP image on this machine. The
|
|
87
|
+
whole registration request is capped at 25 MiB, so the icon must stay under that.
|
|
88
|
+
Omit it to keep the current icon when re-registering.
|
|
81
89
|
8. **Confirm** to the user: report the app name, public URL, final `bound`,
|
|
82
90
|
`relayConnected`, and `live` values, and that it now appears in their chat with this
|
|
83
91
|
agent (open the「…」menu → the app tile).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawchat-orchestration
|
|
3
|
-
version: 2.0.
|
|
3
|
+
version: 2.0.2
|
|
4
4
|
description: Use when the owner asks this agent to manage their OTHER ClawChat agents or their groups — 编排 / orchestrate a fleet, read or rewrite another agent's 提示词 / system prompt / behavior, 禁言 / mute an agent, change 回复模式 / reply mode, stop 刷屏 / flooding in a group, 建群 / create a group of agents, add or remove agents from a group, or 签发连接码 / issue a connect code.
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -82,7 +82,7 @@ The backend enforces these. Violating one is a failed call, not a warning.
|
|
|
82
82
|
`replyMode`, `batchDelaySeconds`. `replyMode` has exactly two values, `"all"`
|
|
83
83
|
or `"mention"`. `batchDelaySeconds` is 1–3600 (default 10). Omitted
|
|
84
84
|
parameters are left unchanged.
|
|
85
|
-
- `clawchat_orchestrate_create_connect_code` — **no parameters**. The code is valid
|
|
85
|
+
- `clawchat_orchestrate_create_connect_code` — **no parameters**. The code is valid 45 minutes.
|
|
86
86
|
|
|
87
87
|
### What this surface deliberately cannot do
|
|
88
88
|
|
package/skills/manifest.json
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"bytes": 16656
|
|
10
10
|
},
|
|
11
11
|
"clawchat-liveware": {
|
|
12
|
-
"version": "1.2.
|
|
12
|
+
"version": "1.2.3",
|
|
13
13
|
"path": "shared/clawchat-liveware/SKILL.md",
|
|
14
|
-
"sha256": "
|
|
15
|
-
"bytes":
|
|
14
|
+
"sha256": "2ed150bad7972d452b5b437d7c524ab24c27b0677419e590f087f50b7e7a3cdd",
|
|
15
|
+
"bytes": 12591
|
|
16
16
|
},
|
|
17
17
|
"clawchat-liveware-dev": {
|
|
18
18
|
"version": "1.0.0",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"bytes": 13938
|
|
34
34
|
},
|
|
35
35
|
"clawchat-orchestration": {
|
|
36
|
-
"version": "2.0.
|
|
36
|
+
"version": "2.0.2",
|
|
37
37
|
"path": "shared/clawchat-orchestration/SKILL.md",
|
|
38
|
-
"sha256": "
|
|
38
|
+
"sha256": "e32f29c5f41a7621732625a58a9f3d6fe365d7dde9bdcd892f9623e7fc180414",
|
|
39
39
|
"bytes": 9477
|
|
40
40
|
}
|
|
41
41
|
},
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
"bytes": 23959
|
|
48
48
|
},
|
|
49
49
|
"clawchat-liveware": {
|
|
50
|
-
"version": "1.2.
|
|
50
|
+
"version": "1.2.3",
|
|
51
51
|
"path": "shared/clawchat-liveware/SKILL.md",
|
|
52
|
-
"sha256": "
|
|
53
|
-
"bytes":
|
|
52
|
+
"sha256": "2ed150bad7972d452b5b437d7c524ab24c27b0677419e590f087f50b7e7a3cdd",
|
|
53
|
+
"bytes": 12591
|
|
54
54
|
},
|
|
55
55
|
"clawchat-liveware-dev": {
|
|
56
56
|
"version": "1.0.0",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
"bytes": 13838
|
|
72
72
|
},
|
|
73
73
|
"clawchat-orchestration": {
|
|
74
|
-
"version": "2.0.
|
|
74
|
+
"version": "2.0.2",
|
|
75
75
|
"path": "shared/clawchat-orchestration/SKILL.md",
|
|
76
|
-
"sha256": "
|
|
76
|
+
"sha256": "e32f29c5f41a7621732625a58a9f3d6fe365d7dde9bdcd892f9623e7fc180414",
|
|
77
77
|
"bytes": 9477
|
|
78
78
|
}
|
|
79
79
|
}
|
package/src/api-client.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ClawlingApiError,
|
|
3
3
|
type AgentAppView,
|
|
4
|
+
type LivewareIconUpload,
|
|
5
|
+
type LivewareView,
|
|
4
6
|
type AgentConnectCheckInput,
|
|
5
7
|
type AgentConnectCheckResult,
|
|
6
8
|
type AgentMetadataPatch,
|
|
@@ -252,7 +254,13 @@ export interface OpenclawClawlingApiClient {
|
|
|
252
254
|
* cache (never throw / never block message handling).
|
|
253
255
|
*/
|
|
254
256
|
getMyPermissions(): Promise<PermissionPolicy>;
|
|
255
|
-
registerApp(params: {
|
|
257
|
+
registerApp(params: {
|
|
258
|
+
name: string;
|
|
259
|
+
appId: string;
|
|
260
|
+
url: string;
|
|
261
|
+
subtitle?: string;
|
|
262
|
+
icon?: LivewareIconUpload;
|
|
263
|
+
}): Promise<{ app: AgentAppView }>;
|
|
256
264
|
listApps(): Promise<{ apps: AgentAppView[] }>;
|
|
257
265
|
unregisterApp(appId: string): Promise<{ deleted: boolean }>;
|
|
258
266
|
}
|
|
@@ -604,6 +612,33 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
604
612
|
return obj as UploadResult;
|
|
605
613
|
}
|
|
606
614
|
|
|
615
|
+
function isLivewareView(v: unknown): v is LivewareView {
|
|
616
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
617
|
+
const o = v as Record<string, unknown>;
|
|
618
|
+
const optionalString = (x: unknown) => x === undefined || typeof x === "string";
|
|
619
|
+
return (
|
|
620
|
+
typeof o.id === "string" &&
|
|
621
|
+
typeof o.liveware_id === "string" &&
|
|
622
|
+
o.liveware_id !== "" &&
|
|
623
|
+
typeof o.name === "string" &&
|
|
624
|
+
typeof o.url === "string" &&
|
|
625
|
+
optionalString(o.subtitle) &&
|
|
626
|
+
optionalString(o.icon_url)
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function livewareToAppView(v: LivewareView): AgentAppView {
|
|
631
|
+
return {
|
|
632
|
+
id: v.id,
|
|
633
|
+
app_id: v.liveware_id,
|
|
634
|
+
liveware_id: v.liveware_id,
|
|
635
|
+
name: v.name,
|
|
636
|
+
subtitle: v.subtitle ?? "",
|
|
637
|
+
icon_url: v.icon_url ?? "",
|
|
638
|
+
url: v.url,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
607
642
|
function assertNonBlankId(value: string, label: string): void {
|
|
608
643
|
if (!value.trim()) {
|
|
609
644
|
throw new ClawlingApiError("validation", `${label} is required`);
|
|
@@ -761,19 +796,46 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
761
796
|
async orchGetConnectCode(code): Promise<unknown> {
|
|
762
797
|
return await orchCall("GET", `${ORCH}/connect-codes/${encodeURIComponent(code)}`);
|
|
763
798
|
},
|
|
764
|
-
async registerApp(params
|
|
799
|
+
async registerApp(params): Promise<{ app: AgentAppView }> {
|
|
765
800
|
assertNonBlankId(params.appId, "registerApp: appId");
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
801
|
+
// Multipart even without an icon: the liveware route reads form fields.
|
|
802
|
+
const fd = new FormData();
|
|
803
|
+
fd.set("name", params.name);
|
|
804
|
+
fd.set("liveware_id", params.appId);
|
|
805
|
+
fd.set("url", params.url);
|
|
806
|
+
// The server only replaces a subtitle when the new one is non-empty, so
|
|
807
|
+
// an empty value cannot clear it; do not send one.
|
|
808
|
+
const subtitle = params.subtitle?.trim();
|
|
809
|
+
if (subtitle) fd.set("subtitle", subtitle);
|
|
810
|
+
if (params.icon) {
|
|
811
|
+
const file = new File([new Uint8Array(params.icon.buffer)], params.icon.filename, {
|
|
812
|
+
type: params.icon.mime,
|
|
813
|
+
});
|
|
814
|
+
fd.set("icon", file);
|
|
815
|
+
}
|
|
816
|
+
const data = await call<{ liveware?: LivewareView }>("POST", "/v1/agents/me/liveware", { body: fd });
|
|
817
|
+
if (!isLivewareView(data?.liveware)) {
|
|
818
|
+
throw new ClawlingApiError("transport", "invalid liveware response: missing liveware entry", {
|
|
819
|
+
path: "/v1/agents/me/liveware",
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return { app: livewareToAppView(data.liveware) };
|
|
770
823
|
},
|
|
771
824
|
async listApps(): Promise<{ apps: AgentAppView[] }> {
|
|
772
|
-
|
|
825
|
+
const data = await call<{ liveware?: unknown }>("GET", "/v1/agents/me/liveware");
|
|
826
|
+
// A malformed body must not read as "no apps": the liveware sample
|
|
827
|
+
// bootstrap would take that as a fresh account and register a duplicate.
|
|
828
|
+
const list = data?.liveware;
|
|
829
|
+
if (!Array.isArray(list) || !list.every(isLivewareView)) {
|
|
830
|
+
throw new ClawlingApiError("transport", "invalid liveware response: malformed liveware list", {
|
|
831
|
+
path: "/v1/agents/me/liveware",
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return { apps: list.map(livewareToAppView) };
|
|
773
835
|
},
|
|
774
836
|
async unregisterApp(appId: string): Promise<{ deleted: boolean }> {
|
|
775
837
|
assertNonBlankId(appId, "unregisterApp: appId");
|
|
776
|
-
return await call<{ deleted: boolean }>("DELETE", `/v1/agents/me/
|
|
838
|
+
return await call<{ deleted: boolean }>("DELETE", `/v1/agents/me/liveware/${encodeURIComponent(appId)}`);
|
|
777
839
|
},
|
|
778
840
|
async searchUsers(params): Promise<{ users: UserSearchHit[] }> {
|
|
779
841
|
const sp = new URLSearchParams();
|
package/src/api-types.ts
CHANGED
|
@@ -164,7 +164,32 @@ export interface AgentConnectCheckResult {
|
|
|
164
164
|
bound_agent?: boolean;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
/** Wire shape of one entry on `/v1/agents/me/liveware`. */
|
|
168
|
+
export type LivewareView = {
|
|
169
|
+
id: string;
|
|
170
|
+
liveware_id?: string;
|
|
171
|
+
name: string;
|
|
172
|
+
subtitle?: string;
|
|
173
|
+
icon_url?: string;
|
|
174
|
+
url: string;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Shape returned by the `clawchat_*_app` tools. `app_id` mirrors
|
|
179
|
+
* `liveware_id` so callers written against the older app routes keep working.
|
|
180
|
+
*/
|
|
181
|
+
export type AgentAppView = {
|
|
182
|
+
id: string;
|
|
183
|
+
app_id?: string;
|
|
184
|
+
liveware_id?: string;
|
|
185
|
+
name: string;
|
|
186
|
+
subtitle?: string;
|
|
187
|
+
icon_url?: string;
|
|
188
|
+
url: string;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** An icon file part for `registerApp`. */
|
|
192
|
+
export type LivewareIconUpload = { buffer: Buffer | Uint8Array; filename: string; mime: string };
|
|
168
193
|
|
|
169
194
|
export type ClawlingApiErrorKind =
|
|
170
195
|
| "auth" // 401 / 403 — token bad or expired
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { LivewareIconUpload } from "./api-types.ts";
|
|
5
|
+
|
|
6
|
+
/** Server-side cap on the WHOLE registration request (default configuration). */
|
|
7
|
+
export const LIVEWARE_REQUEST_MAX_BYTES = 25 * 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Fixed reserve for the multipart envelope (boundaries and part headers).
|
|
10
|
+
* The text fields' own UTF-8 bytes are subtracted on top of this, so an icon
|
|
11
|
+
* that passes local validation also fits the server's request cap. Keep in
|
|
12
|
+
* sync with the Hermes plugin.
|
|
13
|
+
*/
|
|
14
|
+
export const LIVEWARE_MULTIPART_OVERHEAD_BYTES = 64 * 1024;
|
|
15
|
+
export const LIVEWARE_SUBTITLE_MAX_CHARS = 200;
|
|
16
|
+
|
|
17
|
+
/** Largest icon that fits in one request next to text fields of `textFieldBytes`. */
|
|
18
|
+
export function livewareIconMaxBytes(textFieldBytes: number): number {
|
|
19
|
+
return LIVEWARE_REQUEST_MAX_BYTES - LIVEWARE_MULTIPART_OVERHEAD_BYTES - textFieldBytes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** UTF-8 byte length of the multipart text fields (and icon filename). */
|
|
23
|
+
export function livewareTextFieldBytes(values: readonly (string | undefined)[]): number {
|
|
24
|
+
let n = 0;
|
|
25
|
+
for (const v of values) if (v) n += Buffer.byteLength(v, "utf8");
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalise a subtitle the way it is sent: trimmed, one line, at most 200
|
|
31
|
+
* characters. An omitted, empty or blank subtitle becomes `undefined` and is
|
|
32
|
+
* not sent. The server then keeps the existing subtitle, because it only
|
|
33
|
+
* replaces a subtitle when the new one is non-empty.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeLivewareSubtitle(
|
|
36
|
+
raw: unknown,
|
|
37
|
+
): { ok: true; value: string | undefined } | { ok: false; message: string } {
|
|
38
|
+
if (raw === undefined || raw === null) return { ok: true, value: undefined };
|
|
39
|
+
if (typeof raw !== "string") return { ok: false, message: "subtitle must be a string" };
|
|
40
|
+
// Line breaks are checked on the raw value: trimming would hide a trailing
|
|
41
|
+
// "\n" and let a caller believe a multi-line value was accepted.
|
|
42
|
+
if (/[\r\n]/.test(raw)) return { ok: false, message: "subtitle must be one line (no line breaks)" };
|
|
43
|
+
const value = raw.trim();
|
|
44
|
+
if (!value) return { ok: true, value: undefined };
|
|
45
|
+
if ([...value].length > LIVEWARE_SUBTITLE_MAX_CHARS) {
|
|
46
|
+
return { ok: false, message: "subtitle must be at most 200 characters" };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, value };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect the icon type from its leading bytes, mirroring the server, which
|
|
53
|
+
* sniffs the content and ignores the declared type and file extension.
|
|
54
|
+
*/
|
|
55
|
+
export function sniffLivewareIconMime(head: Uint8Array): string | undefined {
|
|
56
|
+
const b = head;
|
|
57
|
+
if (
|
|
58
|
+
b.length >= 8 &&
|
|
59
|
+
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
60
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a
|
|
61
|
+
) {
|
|
62
|
+
return "image/png";
|
|
63
|
+
}
|
|
64
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
65
|
+
return "image/jpeg";
|
|
66
|
+
}
|
|
67
|
+
const ascii = (from: number, to: number) => String.fromCharCode(...b.subarray(from, to));
|
|
68
|
+
if (b.length >= 14 && ascii(0, 4) === "RIFF" && ascii(8, 14) === "WEBPVP") {
|
|
69
|
+
return "image/webp";
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function errMessage(err: unknown): string {
|
|
75
|
+
return err instanceof Error ? err.message : String(err);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read and validate a local liveware icon file. Returns either the upload
|
|
80
|
+
* part or a human-readable validation message; filesystem errors never throw.
|
|
81
|
+
* `textFieldBytes` is the UTF-8 size of the other multipart fields, used to
|
|
82
|
+
* keep the whole request under the server cap.
|
|
83
|
+
*/
|
|
84
|
+
export function readLivewareIcon(
|
|
85
|
+
iconPath: string,
|
|
86
|
+
textFieldBytes = 0,
|
|
87
|
+
): { ok: true; icon: LivewareIconUpload } | { ok: false; message: string } {
|
|
88
|
+
if (!iconPath || !path.isAbsolute(iconPath)) {
|
|
89
|
+
return { ok: false, message: "iconPath must be an absolute local path" };
|
|
90
|
+
}
|
|
91
|
+
let stat: fs.Stats;
|
|
92
|
+
try {
|
|
93
|
+
stat = fs.statSync(iconPath);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, message: `cannot stat ${iconPath}: ${errMessage(err)}` };
|
|
96
|
+
}
|
|
97
|
+
if (!stat.isFile()) {
|
|
98
|
+
return { ok: false, message: `${iconPath} is not a regular file` };
|
|
99
|
+
}
|
|
100
|
+
const filename = path.basename(iconPath);
|
|
101
|
+
const max = livewareIconMaxBytes(textFieldBytes + Buffer.byteLength(filename, "utf8"));
|
|
102
|
+
const tooLarge = (size: number) =>
|
|
103
|
+
`icon too large (${size} bytes; max ${max} bytes for this request: the 25MB request limit minus multipart overhead)`;
|
|
104
|
+
if (stat.size > max) {
|
|
105
|
+
return { ok: false, message: tooLarge(stat.size) };
|
|
106
|
+
}
|
|
107
|
+
let buffer: Buffer;
|
|
108
|
+
try {
|
|
109
|
+
buffer = fs.readFileSync(iconPath);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { ok: false, message: `cannot read ${iconPath}: ${errMessage(err)}` };
|
|
112
|
+
}
|
|
113
|
+
// The file may have grown between stat and read.
|
|
114
|
+
if (buffer.length > max) {
|
|
115
|
+
return { ok: false, message: tooLarge(buffer.length) };
|
|
116
|
+
}
|
|
117
|
+
const mime = sniffLivewareIconMime(buffer.subarray(0, 16));
|
|
118
|
+
if (!mime) {
|
|
119
|
+
return { ok: false, message: `icon must be a PNG, JPEG or WebP image (checked from the file's bytes): ${iconPath}` };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, icon: { buffer, filename, mime } };
|
|
122
|
+
}
|
package/src/skill-update.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
|
|
|
71
71
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
72
72
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
73
73
|
*/
|
|
74
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.15.
|
|
74
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.15.2";
|
|
75
75
|
|
|
76
76
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
77
77
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
package/src/tools-schema.ts
CHANGED
|
@@ -439,6 +439,18 @@ export const ClawchatRegisterAppSchema = Type.Object({
|
|
|
439
439
|
name: Type.String({ minLength: 1, description: "Human-readable app name shown on the launcher tile." }),
|
|
440
440
|
appId: Type.String({ minLength: 1, description: "The liveware app id from `liveware app create`/`liveware app list`." }),
|
|
441
441
|
url: Type.String({ minLength: 1, description: "Public tunnel URL from `liveware tunnel bind` (http/https)." }),
|
|
442
|
+
subtitle: Type.Optional(
|
|
443
|
+
Type.String({
|
|
444
|
+
description:
|
|
445
|
+
"Optional one-line subtitle for the tile (one line, max 200 characters, surrounding spaces trimmed). An omitted or empty subtitle keeps the current one: re-registering cannot clear a subtitle.",
|
|
446
|
+
}),
|
|
447
|
+
),
|
|
448
|
+
iconPath: Type.Optional(
|
|
449
|
+
Type.String({
|
|
450
|
+
description:
|
|
451
|
+
"Optional absolute local path of the tile icon: a PNG, JPEG or WebP image, under 25MB (the whole request is capped at 25MB). Omit to keep the current icon on re-registration.",
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
442
454
|
});
|
|
443
455
|
export type ClawchatRegisterAppParams = Static<typeof ClawchatRegisterAppSchema>;
|
|
444
456
|
|
package/src/tools.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { OpenClawAgentToolResult } from "openclaw/plugin-sdk/agent-harness-
|
|
|
5
5
|
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
|
|
6
6
|
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
7
7
|
import { resolveLivewarePath } from "./liveware-cli.ts";
|
|
8
|
+
import { livewareTextFieldBytes, normalizeLivewareSubtitle, readLivewareIcon } from "./liveware-icon.ts";
|
|
9
|
+
import type { LivewareIconUpload } from "./api-types.ts";
|
|
8
10
|
import {
|
|
9
11
|
ClawlingApiError,
|
|
10
12
|
type Profile,
|
|
@@ -1667,13 +1669,28 @@ export function registerOpenclawClawlingTools(
|
|
|
1667
1669
|
label: "Register ClawChat App",
|
|
1668
1670
|
description: toolDescription(
|
|
1669
1671
|
"Register a liveware-tunneled web app to ClawChat so it appears in the owner's chat with this agent. " +
|
|
1670
|
-
"Call AFTER `liveware tunnel bind` returns a public URL. Params: name, appId (liveware app id), url (public URL)
|
|
1672
|
+
"Call AFTER `liveware tunnel bind` returns a public URL. Params: name, appId (liveware app id), url (public URL), " +
|
|
1673
|
+
"optional subtitle (one line, max 200 characters) and optional iconPath (absolute local PNG/JPEG/WebP file, under 25MB). " +
|
|
1674
|
+
"Registering the same appId again updates that tile: name and url are replaced; subtitle and icon only when given " +
|
|
1675
|
+
"(an empty subtitle keeps the current one, so re-registering cannot clear a subtitle).",
|
|
1671
1676
|
),
|
|
1672
1677
|
parameters: ClawchatRegisterAppSchema,
|
|
1673
1678
|
async execute(_callId, params) {
|
|
1674
1679
|
return await recordClawchatToolCall(accountId, "clawchat_register_app", params, async () => {
|
|
1675
1680
|
const p = params as ClawchatRegisterAppParams;
|
|
1676
|
-
|
|
1681
|
+
const sub = normalizeLivewareSubtitle(p.subtitle);
|
|
1682
|
+
if (!sub.ok) return validationError(`clawchat-plugin-openclaw: ${sub.message}`);
|
|
1683
|
+
const subtitle = sub.value;
|
|
1684
|
+
let icon: LivewareIconUpload | undefined;
|
|
1685
|
+
if (p.iconPath !== undefined && p.iconPath !== "") {
|
|
1686
|
+
const textBytes = livewareTextFieldBytes([p.name, p.appId, p.url, subtitle]);
|
|
1687
|
+
const read = readLivewareIcon(String(p.iconPath), textBytes);
|
|
1688
|
+
if (!read.ok) return validationError(`clawchat-plugin-openclaw: ${read.message}`);
|
|
1689
|
+
icon = read.icon;
|
|
1690
|
+
}
|
|
1691
|
+
return await withClient(accountId, (c) =>
|
|
1692
|
+
c.registerApp({ name: p.name, appId: p.appId, url: p.url, subtitle, icon }),
|
|
1693
|
+
);
|
|
1677
1694
|
});
|
|
1678
1695
|
},
|
|
1679
1696
|
};
|
|
@@ -2065,7 +2082,7 @@ export function registerOpenclawClawlingTools(
|
|
|
2065
2082
|
name: "clawchat_orchestrate_create_connect_code",
|
|
2066
2083
|
label: "Issue A Connect Code",
|
|
2067
2084
|
description: toolDescription(
|
|
2068
|
-
"Mint a connect code on the owner's behalf, valid
|
|
2085
|
+
"Mint a connect code on the owner's behalf, valid 45 minutes. " +
|
|
2069
2086
|
"TRIGGER — invoke when the owner asks for a code to connect a new agent. " +
|
|
2070
2087
|
"The rate limit bucket belongs to the owner and is shared with their own manual issuance, so do not retry on a limit.",
|
|
2071
2088
|
),
|