@clawling/clawchat-plugin-openclaw 2026.6.24-1 → 2026.6.30-1

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.
@@ -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 member-backend codes.go).
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) {
@@ -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
- // (member-backend) is down — backoff-reconnect with the SAME token and do
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.24-1",
3
+ "version": "2026.6.30-1",
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",
@@ -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 member-backend. When
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" },