@clawling/clawchat-plugin-openclaw 2026.6.24-1 → 2026.6.30-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/README.md +11 -3
- package/dist/src/api-client.js +3 -0
- package/dist/src/inbound.js +21 -0
- package/dist/src/login.runtime.js +2 -0
- package/dist/src/reply-dispatcher.js +1 -1
- package/dist/src/runtime.js +112 -5
- package/dist/src/skill-update.js +559 -0
- package/dist/src/tools.js +1 -1
- package/dist/src/ws-client.js +1 -1
- package/package.json +18 -1
- package/skills/clawchat/SKILL.md +1 -0
- package/skills/liveware-app/SKILL.md +1 -0
- package/src/api-client.ts +13 -2
- package/src/inbound.ts +41 -0
- package/src/login.runtime.ts +2 -0
- package/src/reply-dispatcher.ts +1 -1
- package/src/runtime.ts +134 -5
- package/src/skill-update.ts +752 -0
- package/src/tools.ts +1 -1
- package/src/ws-client.ts +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
/**
|
|
8
|
+
* Conversational, adapter-driven skill hot-update for the OpenClaw ClawChat
|
|
9
|
+
* plugin.
|
|
10
|
+
*
|
|
11
|
+
* member-backend fires a content-free `notify.signal` with
|
|
12
|
+
* `payload.type === "clawchat.skill.update.check"` at this agent. On receipt the
|
|
13
|
+
* adapter (not the LLM) checks the official skill source for a newer version of
|
|
14
|
+
* each bundled skill; if one exists it asks the owner in their direct chat for
|
|
15
|
+
* consent, and on an affirmative owner reply it ATOMICALLY writes the new
|
|
16
|
+
* `SKILL.md` into the OpenClaw-managed skills dir (`<stateDir>/skills/<id>/`).
|
|
17
|
+
* The host chokidar-watches that dir (`src/skills/runtime/refresh.ts:107`) and
|
|
18
|
+
* loads managed skills at HIGHER precedence than plugin-bundled ones
|
|
19
|
+
* (managed=3 > bundled=2, `src/skills/loading/workspace.ts:1220-1239`), so the
|
|
20
|
+
* write both overrides the bundled copy of the same id AND bumps the skills
|
|
21
|
+
* snapshot to rebuild the system prompt next turn — no restart, no
|
|
22
|
+
* `config reload`. See {@link resolveManagedSkillsDir} for why this dir (not
|
|
23
|
+
* the bundled `./skills`, which lives in node_modules → may be read-only and is
|
|
24
|
+
* clobbered on `openclaw plugins update`).
|
|
25
|
+
*
|
|
26
|
+
* Design notes:
|
|
27
|
+
* - The version-check helpers (`parseSkillsManifest` / `checkSkillUpdate` /
|
|
28
|
+
* `fetchSkillMarkdown`) are a self-contained port of the reference
|
|
29
|
+
* implementation in `@clawling/clawchat-plugin-install-cli`
|
|
30
|
+
* (`packages/core/src/skills/check-update.ts`). That package is
|
|
31
|
+
* workspace-private, so the logic is duplicated here rather than imported.
|
|
32
|
+
* - Strict semver compare mirrors the reference `parseComparableVersion`
|
|
33
|
+
* (`X.Y[.Z[.W]][-buildnum]`).
|
|
34
|
+
* - All network IO goes through an injected `FetchLike`; tests never touch the
|
|
35
|
+
* real network.
|
|
36
|
+
*
|
|
37
|
+
* Cross-language contract / spec: see
|
|
38
|
+
* `ops/agent-plugin/skill-dynamic-update-plan.md` (§2, §3 phase 4,
|
|
39
|
+
* §4 #5, §6.4, §6.7, §7.2).
|
|
40
|
+
*/
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants — fixed contract (must match the install-cli `config.ts`).
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Canonical, official source for ClawChat agent skill markdown. Hard-coded on
|
|
46
|
+
* purpose: a skill-update trigger signal NEVER carries a URL or ref, only a
|
|
47
|
+
* version that maps to a git ref. `clawling` is a public org so raw fetches are
|
|
48
|
+
* unauthenticated.
|
|
49
|
+
*/
|
|
50
|
+
export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/clawchat-plugin-install-cli";
|
|
51
|
+
/**
|
|
52
|
+
* Default git ref for the skills tree. Production callers SHOULD pin an
|
|
53
|
+
* immutable `skills-vX.Y.Z` tag instead of tracking the moving `main`.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_SKILLS_REF = "main";
|
|
56
|
+
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
57
|
+
export const MAX_SKILL_BYTES = 256 * 1024;
|
|
58
|
+
/** This adapter's host target inside `skills/manifest.json`. */
|
|
59
|
+
export const SKILL_TARGET = "openclaw";
|
|
60
|
+
/** Skill ids this OpenClaw plugin bundles and can hot-update. */
|
|
61
|
+
export const OPENCLAW_SKILL_IDS = ["clawchat", "liveware-app"];
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Strict semver compare (ported from install-cli `metadata.ts`).
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
function parseComparableVersion(version) {
|
|
66
|
+
const match = version.match(/^(\d+(?:\.\d+){1,3})(?:-(\d+))?$/);
|
|
67
|
+
if (!match) {
|
|
68
|
+
throw new Error(`unsupported version: ${version}`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
parts: (match[1] ?? "").split(".").map(Number),
|
|
72
|
+
build: match[2] ? Number(match[2]) : 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function compareVersions(a, b) {
|
|
76
|
+
const left = parseComparableVersion(a);
|
|
77
|
+
const right = parseComparableVersion(b);
|
|
78
|
+
const width = Math.max(left.parts.length, right.parts.length);
|
|
79
|
+
for (let i = 0; i < width; i += 1) {
|
|
80
|
+
const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0);
|
|
81
|
+
if (diff !== 0) {
|
|
82
|
+
return diff > 0 ? 1 : -1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const buildDiff = left.build - right.build;
|
|
86
|
+
if (buildDiff !== 0) {
|
|
87
|
+
return buildDiff > 0 ? 1 : -1;
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/** True when `candidate` is strictly newer than `current`. */
|
|
92
|
+
export function isVersionOlder(current, candidate) {
|
|
93
|
+
return compareVersions(current, candidate) < 0;
|
|
94
|
+
}
|
|
95
|
+
function skillsBase(base, ref) {
|
|
96
|
+
return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/skills`;
|
|
97
|
+
}
|
|
98
|
+
export function manifestUrl(ref = DEFAULT_SKILLS_REF, base) {
|
|
99
|
+
return `${skillsBase(base, ref)}/manifest.json`;
|
|
100
|
+
}
|
|
101
|
+
export function skillContentUrl(entryPath, ref = DEFAULT_SKILLS_REF, base) {
|
|
102
|
+
return `${skillsBase(base, ref)}/${entryPath.replace(/^\/+/, "")}`;
|
|
103
|
+
}
|
|
104
|
+
function asEntry(value, where) {
|
|
105
|
+
if (!value || typeof value !== "object") {
|
|
106
|
+
throw new Error(`skills manifest entry ${where} is not an object`);
|
|
107
|
+
}
|
|
108
|
+
const v = value;
|
|
109
|
+
const version = typeof v.version === "string" ? v.version.trim() : "";
|
|
110
|
+
const entryPath = typeof v.path === "string" ? v.path.trim() : "";
|
|
111
|
+
const sha256 = typeof v.sha256 === "string" ? v.sha256.trim().toLowerCase() : "";
|
|
112
|
+
const bytes = typeof v.bytes === "number" ? v.bytes : NaN;
|
|
113
|
+
if (!version)
|
|
114
|
+
throw new Error(`skills manifest entry ${where} missing version`);
|
|
115
|
+
if (!entryPath)
|
|
116
|
+
throw new Error(`skills manifest entry ${where} missing path`);
|
|
117
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) {
|
|
118
|
+
throw new Error(`skills manifest entry ${where} has invalid sha256`);
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isInteger(bytes) || bytes < 0) {
|
|
121
|
+
throw new Error(`skills manifest entry ${where} has invalid bytes`);
|
|
122
|
+
}
|
|
123
|
+
return { version, path: entryPath, sha256, bytes };
|
|
124
|
+
}
|
|
125
|
+
/** Parse and validate raw manifest text. */
|
|
126
|
+
export function parseSkillsManifest(text) {
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(text);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
throw new Error(`failed to parse skills manifest: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
if (!parsed || typeof parsed !== "object") {
|
|
135
|
+
throw new Error("skills manifest must be a JSON object");
|
|
136
|
+
}
|
|
137
|
+
const data = parsed;
|
|
138
|
+
if (data.schema !== 1) {
|
|
139
|
+
throw new Error(`unsupported skills manifest schema: ${JSON.stringify(data.schema)}`);
|
|
140
|
+
}
|
|
141
|
+
if (!data.skills || typeof data.skills !== "object") {
|
|
142
|
+
throw new Error("skills manifest missing `skills`");
|
|
143
|
+
}
|
|
144
|
+
const skills = {};
|
|
145
|
+
for (const [target, entries] of Object.entries(data.skills)) {
|
|
146
|
+
if (!entries || typeof entries !== "object") {
|
|
147
|
+
throw new Error(`skills manifest target ${target} is not an object`);
|
|
148
|
+
}
|
|
149
|
+
skills[target] = {};
|
|
150
|
+
for (const [skillId, entry] of Object.entries(entries)) {
|
|
151
|
+
skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { schema: 1, skills };
|
|
155
|
+
}
|
|
156
|
+
async function fetchText(url, fetchFn) {
|
|
157
|
+
let response;
|
|
158
|
+
try {
|
|
159
|
+
response = await fetchFn(url, { method: "GET" });
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
throw new Error(`fetch ${url} failed: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
throw new Error(`fetch ${url} returned status ${response.status}`);
|
|
166
|
+
}
|
|
167
|
+
return response.text();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Read the official manifest for the `openclaw` target and compare each skill's
|
|
171
|
+
* offered version against the locally installed `current` map. A skill missing
|
|
172
|
+
* from `current` is reported as `hasUpdate: true`.
|
|
173
|
+
*/
|
|
174
|
+
export async function checkSkillUpdate(options) {
|
|
175
|
+
const ref = options.ref ?? DEFAULT_SKILLS_REF;
|
|
176
|
+
const text = await fetchText(manifestUrl(ref, options.base), options.fetchFn);
|
|
177
|
+
const manifest = parseSkillsManifest(text);
|
|
178
|
+
const targetSkills = manifest.skills[SKILL_TARGET];
|
|
179
|
+
if (!targetSkills) {
|
|
180
|
+
throw new Error(`skills manifest has no entry for target ${SKILL_TARGET}`);
|
|
181
|
+
}
|
|
182
|
+
const results = [];
|
|
183
|
+
for (const [skillId, entry] of Object.entries(targetSkills)) {
|
|
184
|
+
const current = options.current[skillId] ?? null;
|
|
185
|
+
const hasUpdate = current === null ? true : isVersionOlder(current, entry.version);
|
|
186
|
+
results.push({
|
|
187
|
+
skillId,
|
|
188
|
+
current,
|
|
189
|
+
latest: entry.version,
|
|
190
|
+
hasUpdate,
|
|
191
|
+
path: entry.path,
|
|
192
|
+
sha256: entry.sha256,
|
|
193
|
+
bytes: entry.bytes,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { ref, results, hasUpdate: results.some((r) => r.hasUpdate) };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Download one skill markdown file and integrity-check it against the manifest
|
|
200
|
+
* entry (size cap + exact sha256). Returns the raw markdown text on success.
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchSkillMarkdown(entry, options) {
|
|
203
|
+
const ref = options.ref ?? DEFAULT_SKILLS_REF;
|
|
204
|
+
const text = await fetchText(skillContentUrl(entry.path, ref, options.base), options.fetchFn);
|
|
205
|
+
const buf = Buffer.from(text, "utf8");
|
|
206
|
+
if (buf.length > MAX_SKILL_BYTES) {
|
|
207
|
+
throw new Error(`skill ${entry.path} is ${buf.length} bytes, over the ${MAX_SKILL_BYTES} cap`);
|
|
208
|
+
}
|
|
209
|
+
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
|
|
210
|
+
if (sha256 !== entry.sha256.toLowerCase()) {
|
|
211
|
+
throw new Error(`skill ${entry.path} sha256 mismatch: got ${sha256}, expected ${entry.sha256}`);
|
|
212
|
+
}
|
|
213
|
+
return text;
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Local skill version resolution (frontmatter `version:`).
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
/** Extract the `version` field from a SKILL.md YAML frontmatter block. */
|
|
219
|
+
export function parseSkillFrontmatterVersion(markdown) {
|
|
220
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
221
|
+
if (!match)
|
|
222
|
+
return null;
|
|
223
|
+
const frontmatter = match[1] ?? "";
|
|
224
|
+
const versionLine = frontmatter
|
|
225
|
+
.split(/\r?\n/)
|
|
226
|
+
.map((line) => line.match(/^version:\s*(.+?)\s*$/))
|
|
227
|
+
.find((m) => m !== null);
|
|
228
|
+
if (!versionLine)
|
|
229
|
+
return null;
|
|
230
|
+
const raw = (versionLine[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
231
|
+
return raw || null;
|
|
232
|
+
}
|
|
233
|
+
/** Read one skill's frontmatter `version` from `<dir>/<skillId>/SKILL.md`. */
|
|
234
|
+
async function readSingleSkillVersion(dir, skillId) {
|
|
235
|
+
try {
|
|
236
|
+
const md = await fs.readFile(path.join(dir, skillId, "SKILL.md"), "utf8");
|
|
237
|
+
return parseSkillFrontmatterVersion(md);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null; // missing / unreadable
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Read the installed version of each requested skill from a SINGLE directory
|
|
245
|
+
* (`<skillsDir>/<skillId>/SKILL.md`). Missing files / missing frontmatter
|
|
246
|
+
* version are simply omitted (treated as "not installed" → update available).
|
|
247
|
+
*/
|
|
248
|
+
export async function readLocalSkillVersions(skillsDir, skillIds = OPENCLAW_SKILL_IDS) {
|
|
249
|
+
const versions = {};
|
|
250
|
+
for (const skillId of skillIds) {
|
|
251
|
+
const version = await readSingleSkillVersion(skillsDir, skillId);
|
|
252
|
+
if (version)
|
|
253
|
+
versions[skillId] = version;
|
|
254
|
+
}
|
|
255
|
+
return versions;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Read the EFFECTIVE installed version of each skill, mirroring the host's
|
|
259
|
+
* managed-over-bundled precedence (`src/skills/loading/workspace.ts:1220-1239`,
|
|
260
|
+
* managed=3 > bundled=2): use the managed copy's frontmatter `version` when
|
|
261
|
+
* `<managedDir>/<id>/SKILL.md` exists, otherwise fall back to the bundled
|
|
262
|
+
* copy's. A skill present in neither is omitted (→ update available).
|
|
263
|
+
*/
|
|
264
|
+
export async function readEffectiveSkillVersions(managedDir, bundledDir, skillIds = OPENCLAW_SKILL_IDS) {
|
|
265
|
+
const versions = {};
|
|
266
|
+
for (const skillId of skillIds) {
|
|
267
|
+
const managed = await readSingleSkillVersion(managedDir, skillId);
|
|
268
|
+
if (managed) {
|
|
269
|
+
versions[skillId] = managed;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (bundledDir) {
|
|
273
|
+
const bundled = await readSingleSkillVersion(bundledDir, skillId);
|
|
274
|
+
if (bundled)
|
|
275
|
+
versions[skillId] = bundled;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return versions;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
|
|
282
|
+
* `OPENCLAW_STATE_DIR` if set; else the dirname of `OPENCLAW_CONFIG_PATH` if
|
|
283
|
+
* set; else `~/.openclaw`.
|
|
284
|
+
*/
|
|
285
|
+
export function resolveStateDir(env = process.env) {
|
|
286
|
+
const stateDir = env.OPENCLAW_STATE_DIR?.trim();
|
|
287
|
+
if (stateDir)
|
|
288
|
+
return stateDir;
|
|
289
|
+
const configPath = env.OPENCLAW_CONFIG_PATH?.trim();
|
|
290
|
+
if (configPath)
|
|
291
|
+
return path.dirname(configPath);
|
|
292
|
+
return path.join(os.homedir(), ".openclaw");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Resolve the OpenClaw-managed skills directory — THE write target for an
|
|
296
|
+
* applied skill update: `<stateDir>/skills`.
|
|
297
|
+
*
|
|
298
|
+
* WHY THIS DIR (and NOT the plugin's bundled `./skills`):
|
|
299
|
+
* - It is chokidar-watched by the host (`src/skills/runtime/refresh.ts:107`),
|
|
300
|
+
* so an atomic write triggers the snapshot rebuild on the next turn — the
|
|
301
|
+
* same activation proven in plan §7.2, without a restart or `config reload`.
|
|
302
|
+
* - Managed skills load at HIGHER precedence than plugin-bundled ones
|
|
303
|
+
* (managed=3 > bundled=2, `src/skills/loading/workspace.ts:1220-1239`), so a
|
|
304
|
+
* managed `SKILL.md` OVERRIDES the bundled copy of the same skill id.
|
|
305
|
+
* - It is writable and persistent across npm installs, whereas the bundled
|
|
306
|
+
* `./skills` lives under `node_modules` → can be read-only and is clobbered
|
|
307
|
+
* on `openclaw plugins update`. This mirrors the Hermes adapter's
|
|
308
|
+
* `$HERMES_HOME/clawchat-skills` design.
|
|
309
|
+
*/
|
|
310
|
+
export function resolveManagedSkillsDir(env = process.env) {
|
|
311
|
+
return path.join(resolveStateDir(env), "skills");
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Resolve the plugin's bundled skills directory.
|
|
315
|
+
*
|
|
316
|
+
* READ-SOURCE ONLY: this dir (the plugin's own `./skills`, declared
|
|
317
|
+
* `"skills": ["./skills"]` in `openclaw.plugin.json`) is used solely to read
|
|
318
|
+
* the first-boot / fallback "current installed version" when no managed copy
|
|
319
|
+
* exists yet. It is NEVER a write target — applied updates go to the
|
|
320
|
+
* OpenClaw-managed dir ({@link resolveManagedSkillsDir}).
|
|
321
|
+
*
|
|
322
|
+
* Resolution walks up from this module's location looking for a directory that
|
|
323
|
+
* contains both `openclaw.plugin.json` and a `skills/` subdir — this works for
|
|
324
|
+
* both the built layout (`dist/src/skill-update.js`) and the source layout
|
|
325
|
+
* (`src/skill-update.ts`).
|
|
326
|
+
*/
|
|
327
|
+
export function resolveBundledSkillsDir(fromUrl = import.meta.url) {
|
|
328
|
+
let dir = path.dirname(fileURLToPath(fromUrl));
|
|
329
|
+
for (let i = 0; i < 8; i += 1) {
|
|
330
|
+
if (existsSync(path.join(dir, "openclaw.plugin.json")) && existsSync(path.join(dir, "skills"))) {
|
|
331
|
+
return path.join(dir, "skills");
|
|
332
|
+
}
|
|
333
|
+
const parent = path.dirname(dir);
|
|
334
|
+
if (parent === dir)
|
|
335
|
+
break;
|
|
336
|
+
dir = parent;
|
|
337
|
+
}
|
|
338
|
+
throw new Error("could not resolve bundled skills dir (no openclaw.plugin.json + skills/ ancestor)");
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Atomically write `<skillsDir>/<skillId>/SKILL.md` with `content`. `mkdir -p`
|
|
342
|
+
* the `<skillsDir>/<skillId>/` dir first (the managed dir may not exist yet).
|
|
343
|
+
*
|
|
344
|
+
* HARD INVARIANT (plan §4 #5): write to a temp file in the SAME directory then
|
|
345
|
+
* `rename` into place. NEVER delete-then-write — a transient "file missing"
|
|
346
|
+
* window would make OpenClaw drop the skill registration and demand a reload.
|
|
347
|
+
*/
|
|
348
|
+
export async function atomicWriteSkill(skillsDir, skillId, content) {
|
|
349
|
+
const dir = path.join(skillsDir, skillId);
|
|
350
|
+
await fs.mkdir(dir, { recursive: true });
|
|
351
|
+
const target = path.join(dir, "SKILL.md");
|
|
352
|
+
const tmp = path.join(dir, `.SKILL.md.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
353
|
+
await fs.writeFile(tmp, content, "utf8");
|
|
354
|
+
try {
|
|
355
|
+
await fs.rename(tmp, target);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const AFFIRM_TOKENS = new Set([
|
|
363
|
+
"更新",
|
|
364
|
+
"更新吧",
|
|
365
|
+
"确认",
|
|
366
|
+
"确认更新",
|
|
367
|
+
"同意",
|
|
368
|
+
"同意更新",
|
|
369
|
+
"好",
|
|
370
|
+
"好的",
|
|
371
|
+
"好啊",
|
|
372
|
+
"可以",
|
|
373
|
+
"行",
|
|
374
|
+
"yes",
|
|
375
|
+
"y",
|
|
376
|
+
"ok",
|
|
377
|
+
"okay",
|
|
378
|
+
"update",
|
|
379
|
+
]);
|
|
380
|
+
const DENY_TOKENS = new Set([
|
|
381
|
+
"取消",
|
|
382
|
+
"取消更新",
|
|
383
|
+
"不更新",
|
|
384
|
+
"不",
|
|
385
|
+
"不要",
|
|
386
|
+
"不用",
|
|
387
|
+
"否",
|
|
388
|
+
"拒绝",
|
|
389
|
+
"算了",
|
|
390
|
+
"no",
|
|
391
|
+
"n",
|
|
392
|
+
"cancel",
|
|
393
|
+
]);
|
|
394
|
+
/**
|
|
395
|
+
* CONSERVATIVE consent parse: only a message that, once stripped of whitespace
|
|
396
|
+
* and common punctuation, equals a single standalone consent token counts as
|
|
397
|
+
* affirm/deny. Anything else (a sentence that merely contains "更新", an
|
|
398
|
+
* unrelated reply, an empty string) is `ambiguous` and must NOT be consumed —
|
|
399
|
+
* it flows on to the normal LLM pipeline while the pending record survives
|
|
400
|
+
* until its timeout. This keeps "soft consent" from misfiring on ordinary chat.
|
|
401
|
+
*/
|
|
402
|
+
export function parseConsent(text) {
|
|
403
|
+
const norm = (text ?? "")
|
|
404
|
+
.trim()
|
|
405
|
+
.replace(/[\s。.!?!?,,、…~~·"'「」『』()()【】\[\]]/g, "")
|
|
406
|
+
.toLowerCase();
|
|
407
|
+
if (!norm)
|
|
408
|
+
return "ambiguous";
|
|
409
|
+
if (AFFIRM_TOKENS.has(norm))
|
|
410
|
+
return "affirm";
|
|
411
|
+
if (DENY_TOKENS.has(norm))
|
|
412
|
+
return "deny";
|
|
413
|
+
return "ambiguous";
|
|
414
|
+
}
|
|
415
|
+
/** Default consent window: a pending ask expires after 30 minutes. */
|
|
416
|
+
export const DEFAULT_CONSENT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
417
|
+
export class PendingConsentStore {
|
|
418
|
+
now;
|
|
419
|
+
pending = null;
|
|
420
|
+
constructor(now = Date.now) {
|
|
421
|
+
this.now = now;
|
|
422
|
+
}
|
|
423
|
+
set(record) {
|
|
424
|
+
this.pending = record;
|
|
425
|
+
}
|
|
426
|
+
/** Returns the live pending record, clearing + returning null if expired. */
|
|
427
|
+
get() {
|
|
428
|
+
if (!this.pending)
|
|
429
|
+
return null;
|
|
430
|
+
if (this.now() >= this.pending.expiresAt) {
|
|
431
|
+
this.pending = null;
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
return this.pending;
|
|
435
|
+
}
|
|
436
|
+
clear() {
|
|
437
|
+
this.pending = null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function describeUpdate(u) {
|
|
441
|
+
return `「${u.skillId}」v${u.current ?? "无"} → v${u.target}`;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Step ③–⑤ (plan §2): on `clawchat.skill.update.check`, check the official
|
|
445
|
+
* source; if any skill has a newer version, message the owner asking for
|
|
446
|
+
* consent and record a pending consent entry. No-update → silent. Returns the
|
|
447
|
+
* recorded pending entry, or `null` when there was nothing to ask.
|
|
448
|
+
*/
|
|
449
|
+
export async function runSkillUpdateCheck(options) {
|
|
450
|
+
const ownerUserId = options.ownerUserId.trim();
|
|
451
|
+
if (!ownerUserId) {
|
|
452
|
+
options.log?.error?.("clawchat skill-update check: no owner user id; cannot ask for consent");
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
const outcome = await checkSkillUpdate({
|
|
456
|
+
current: options.localVersions,
|
|
457
|
+
...(options.ref ? { ref: options.ref } : {}),
|
|
458
|
+
...(options.base ? { base: options.base } : {}),
|
|
459
|
+
fetchFn: options.fetchFn,
|
|
460
|
+
});
|
|
461
|
+
const updates = outcome.results
|
|
462
|
+
.filter((r) => r.hasUpdate)
|
|
463
|
+
.map((r) => ({
|
|
464
|
+
skillId: r.skillId,
|
|
465
|
+
current: r.current,
|
|
466
|
+
target: r.latest,
|
|
467
|
+
path: r.path,
|
|
468
|
+
sha256: r.sha256,
|
|
469
|
+
bytes: r.bytes,
|
|
470
|
+
}));
|
|
471
|
+
if (updates.length === 0) {
|
|
472
|
+
options.log?.info?.("clawchat skill-update check: no updates available");
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const now = (options.now ?? Date.now)();
|
|
476
|
+
const record = {
|
|
477
|
+
ownerUserId,
|
|
478
|
+
ref: outcome.ref,
|
|
479
|
+
...(options.base ? { base: options.base } : {}),
|
|
480
|
+
updates,
|
|
481
|
+
createdAt: now,
|
|
482
|
+
expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
|
|
483
|
+
};
|
|
484
|
+
options.store.set(record);
|
|
485
|
+
const text = `我的技能有更新:${updates.map(describeUpdate).join(";")}。回复「更新」确认,「取消」忽略。`;
|
|
486
|
+
await options.sendOwnerMessage(text);
|
|
487
|
+
options.log?.info?.(`clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`);
|
|
488
|
+
return record;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Step ⑦–⑨ (plan §2): the owner-reply pending-consent gate.
|
|
492
|
+
*
|
|
493
|
+
* Returns `true` when the reply was CONSUMED (affirm → applied + acked, or
|
|
494
|
+
* deny → cleared + acked) and must NOT be forwarded to the LLM. Returns `false`
|
|
495
|
+
* when the reply must flow on to normal handling: no pending record, not from
|
|
496
|
+
* the owner, or an ambiguous reply (pending is kept until timeout).
|
|
497
|
+
*/
|
|
498
|
+
export async function handleOwnerConsentReply(options) {
|
|
499
|
+
const pending = options.store.get();
|
|
500
|
+
if (!pending)
|
|
501
|
+
return false;
|
|
502
|
+
const ownerUserId = options.ownerUserId.trim();
|
|
503
|
+
// Only the owner can consent, and only to their own pending ask.
|
|
504
|
+
if (!ownerUserId || options.senderId !== ownerUserId || pending.ownerUserId !== ownerUserId) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
const verdict = parseConsent(options.text);
|
|
508
|
+
if (verdict === "ambiguous") {
|
|
509
|
+
// Do NOT consume — let the message reach the LLM, keep pending until timeout.
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
if (verdict === "deny") {
|
|
513
|
+
options.store.clear();
|
|
514
|
+
await options.sendOwnerMessage("已取消");
|
|
515
|
+
options.log?.info?.("clawchat skill-update: owner declined; pending cleared");
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
// verdict === "affirm"
|
|
519
|
+
const readLocalVersion = options.readLocalVersion ??
|
|
520
|
+
(async (skillId) => {
|
|
521
|
+
try {
|
|
522
|
+
const md = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"), "utf8");
|
|
523
|
+
return parseSkillFrontmatterVersion(md);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
const applied = [];
|
|
530
|
+
try {
|
|
531
|
+
for (const update of pending.updates) {
|
|
532
|
+
// Idempotency: skip a skill already at the target version on disk.
|
|
533
|
+
const onDisk = await readLocalVersion(update.skillId);
|
|
534
|
+
if (onDisk === update.target) {
|
|
535
|
+
applied.push(update);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const content = await fetchSkillMarkdown({ path: update.path, sha256: update.sha256, bytes: update.bytes }, {
|
|
539
|
+
ref: pending.ref,
|
|
540
|
+
...(pending.base ? { base: pending.base } : options.base ? { base: options.base } : {}),
|
|
541
|
+
fetchFn: options.fetchFn,
|
|
542
|
+
});
|
|
543
|
+
await atomicWriteSkill(options.skillsDir, update.skillId, content);
|
|
544
|
+
applied.push(update);
|
|
545
|
+
options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
// Keep the pending record so the owner can retry with another "更新"; report
|
|
550
|
+
// the failure rather than silently dropping it.
|
|
551
|
+
options.log?.error?.(`clawchat skill-update: apply failed: ${err.message}`);
|
|
552
|
+
await options.sendOwnerMessage("技能更新失败,请稍后再回复「更新」重试。");
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
options.store.clear();
|
|
556
|
+
const summary = applied.map((u) => `「${u.skillId}」v${u.target}`).join("、");
|
|
557
|
+
await options.sendOwnerMessage(`✅ 已更新到 ${summary}`);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
package/dist/src/tools.js
CHANGED
|
@@ -13,7 +13,7 @@ import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPa
|
|
|
13
13
|
import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
|
|
14
14
|
import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetUserProfileSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
|
|
15
15
|
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
16
|
-
// Owner-approval gate business codes (must match
|
|
16
|
+
// Owner-approval gate business codes (must match the ClawChat backend's owner-approval codes).
|
|
17
17
|
const CODE_PENDING_APPROVAL = 21001;
|
|
18
18
|
const CODE_POLICY_FORBIDDEN = 21003;
|
|
19
19
|
function extractRequestId(err) {
|
package/dist/src/ws-client.js
CHANGED
|
@@ -392,7 +392,7 @@ export class ClawChatClient extends EventEmitter {
|
|
|
392
392
|
}
|
|
393
393
|
// §14.1: distinguish upstream auth-service unavailability (5xx) from token
|
|
394
394
|
// rejection (4xx). On a 5xx the token may still be valid and the auth backend
|
|
395
|
-
//
|
|
395
|
+
// is down — backoff-reconnect with the SAME token and do
|
|
396
396
|
// NOT refresh (a 5xx storm must not become a mass token-refresh storm). Until
|
|
397
397
|
// the server emits the distinct 5xx reason, every other hello-fail is treated
|
|
398
398
|
// as a terminal token rejection (the caller acquires a fresh token first).
|
package/package.json
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawling/clawchat-plugin-openclaw",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.30-2",
|
|
4
4
|
"description": "OpenClaw ClawChat channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": "CLAWLING PTE. LTD.",
|
|
7
|
+
"homepage": "https://github.com/clawling/clawchat-plugin-openclaw#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/clawling/clawchat-plugin-openclaw.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/clawling/clawchat-plugin-openclaw/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"clawchat",
|
|
17
|
+
"openclaw",
|
|
18
|
+
"plugin",
|
|
19
|
+
"channel",
|
|
20
|
+
"chat",
|
|
21
|
+
"agent"
|
|
22
|
+
],
|
|
6
23
|
"files": [
|
|
7
24
|
"dist",
|
|
8
25
|
"!dist/**/*.test.js",
|
package/skills/clawchat/SKILL.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawchat
|
|
3
3
|
description: Use when a request involves ClawChat profile, friends, user search, moments/dynamics, comments, reactions, avatar, media, memory, output visibility, read-only conversation lookup, or plugin install/update/activation.
|
|
4
|
+
version: 1.0.0
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# ClawChat
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: liveware-app
|
|
3
3
|
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, and registering the public URL to ClawChat.
|
|
4
|
+
version: 1.0.0
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# liveware App Hosting
|
package/src/api-client.ts
CHANGED
|
@@ -46,6 +46,13 @@ export interface ApiClientOptions {
|
|
|
46
46
|
mediaBaseUrl?: string;
|
|
47
47
|
/** Test override only. Defaults to global `fetch`. */
|
|
48
48
|
fetchImpl?: typeof fetch;
|
|
49
|
+
/**
|
|
50
|
+
* Plugin version string to include as `plugin_version` in the
|
|
51
|
+
* `POST /v1/agents/connect` request body. When omitted the field is not
|
|
52
|
+
* sent (the backend treats it as optional). Pass `resolvePluginVersion()`
|
|
53
|
+
* from `plugin-report.ts` in production callers.
|
|
54
|
+
*/
|
|
55
|
+
pluginVersion?: string;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
@@ -130,7 +137,8 @@ export interface OpenclawClawlingApiClient {
|
|
|
130
137
|
uploadMedia(params: { buffer: Buffer; filename: string; mime?: string }): Promise<UploadResult>;
|
|
131
138
|
/**
|
|
132
139
|
* Exchange an invite code for an agent token.
|
|
133
|
-
* Request body shape: `{ code, platform, type, user_id? }`.
|
|
140
|
+
* Request body shape: `{ code, platform, type, user_id?, plugin_version? }`.
|
|
141
|
+
* `plugin_version` is included when `ApiClientOptions.pluginVersion` is set.
|
|
134
142
|
*/
|
|
135
143
|
agentsConnect(params: {
|
|
136
144
|
/** The invite code entered by the operator. */
|
|
@@ -152,7 +160,7 @@ export interface OpenclawClawlingApiClient {
|
|
|
152
160
|
mime?: string;
|
|
153
161
|
}): Promise<AvatarUploadResult>;
|
|
154
162
|
/**
|
|
155
|
-
* Report this plugin's version + runtime to
|
|
163
|
+
* Report this plugin's version + runtime to the ClawChat backend. When
|
|
156
164
|
* `authenticated`, posts to the agent-JWT self-report endpoint (links the row
|
|
157
165
|
* to the caller's agent/owner); otherwise the public unpaired endpoint.
|
|
158
166
|
*/
|
|
@@ -651,6 +659,9 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
651
659
|
if (userId?.trim()) {
|
|
652
660
|
body.user_id = userId.trim();
|
|
653
661
|
}
|
|
662
|
+
if (opts.pluginVersion?.trim()) {
|
|
663
|
+
body.plugin_version = opts.pluginVersion.trim();
|
|
664
|
+
}
|
|
654
665
|
return await call<AgentConnectResult>("POST", "/v1/agents/connect", {
|
|
655
666
|
// `X-Device-Id` is added globally via `authHeaders` on every request.
|
|
656
667
|
headers: { "content-type": "application/json" },
|