@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.
@@ -0,0 +1,752 @@
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
+ /**
9
+ * Conversational, adapter-driven skill hot-update for the OpenClaw ClawChat
10
+ * plugin.
11
+ *
12
+ * member-backend fires a content-free `notify.signal` with
13
+ * `payload.type === "clawchat.skill.update.check"` at this agent. On receipt the
14
+ * adapter (not the LLM) checks the official skill source for a newer version of
15
+ * each bundled skill; if one exists it asks the owner in their direct chat for
16
+ * consent, and on an affirmative owner reply it ATOMICALLY writes the new
17
+ * `SKILL.md` into the OpenClaw-managed skills dir (`<stateDir>/skills/<id>/`).
18
+ * The host chokidar-watches that dir (`src/skills/runtime/refresh.ts:107`) and
19
+ * loads managed skills at HIGHER precedence than plugin-bundled ones
20
+ * (managed=3 > bundled=2, `src/skills/loading/workspace.ts:1220-1239`), so the
21
+ * write both overrides the bundled copy of the same id AND bumps the skills
22
+ * snapshot to rebuild the system prompt next turn — no restart, no
23
+ * `config reload`. See {@link resolveManagedSkillsDir} for why this dir (not
24
+ * the bundled `./skills`, which lives in node_modules → may be read-only and is
25
+ * clobbered on `openclaw plugins update`).
26
+ *
27
+ * Design notes:
28
+ * - The version-check helpers (`parseSkillsManifest` / `checkSkillUpdate` /
29
+ * `fetchSkillMarkdown`) are a self-contained port of the reference
30
+ * implementation in `@clawling/clawchat-plugin-install-cli`
31
+ * (`packages/core/src/skills/check-update.ts`). That package is
32
+ * workspace-private, so the logic is duplicated here rather than imported.
33
+ * - Strict semver compare mirrors the reference `parseComparableVersion`
34
+ * (`X.Y[.Z[.W]][-buildnum]`).
35
+ * - All network IO goes through an injected `FetchLike`; tests never touch the
36
+ * real network.
37
+ *
38
+ * Cross-language contract / spec: see
39
+ * `ops/agent-plugin/skill-dynamic-update-plan.md` (§2, §3 phase 4,
40
+ * §4 #5, §6.4, §6.7, §7.2).
41
+ */
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants — fixed contract (must match the install-cli `config.ts`).
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Canonical, official source for ClawChat agent skill markdown. Hard-coded on
49
+ * purpose: a skill-update trigger signal NEVER carries a URL or ref, only a
50
+ * version that maps to a git ref. `clawling` is a public org so raw fetches are
51
+ * unauthenticated.
52
+ */
53
+ export const OFFICIAL_SKILLS_BASE =
54
+ "https://raw.githubusercontent.com/clawling/clawchat-plugin-install-cli";
55
+
56
+ /**
57
+ * Default git ref for the skills tree. Production callers SHOULD pin an
58
+ * immutable `skills-vX.Y.Z` tag instead of tracking the moving `main`.
59
+ */
60
+ export const DEFAULT_SKILLS_REF = "main";
61
+
62
+ /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
63
+ export const MAX_SKILL_BYTES = 256 * 1024;
64
+
65
+ /** This adapter's host target inside `skills/manifest.json`. */
66
+ export const SKILL_TARGET = "openclaw";
67
+
68
+ /** Skill ids this OpenClaw plugin bundles and can hot-update. */
69
+ export const OPENCLAW_SKILL_IDS = ["clawchat", "liveware-app"] as const;
70
+ export type OpenclawSkillId = (typeof OPENCLAW_SKILL_IDS)[number];
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Minimal fetch abstraction (compatible with the global `fetch`).
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export interface FetchResponseLike {
77
+ ok: boolean;
78
+ status: number;
79
+ text(): Promise<string>;
80
+ }
81
+ export type FetchLike = (
82
+ url: string,
83
+ init?: { method?: string },
84
+ ) => Promise<FetchResponseLike>;
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Strict semver compare (ported from install-cli `metadata.ts`).
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function parseComparableVersion(version: string): { parts: number[]; build: number } {
91
+ const match = version.match(/^(\d+(?:\.\d+){1,3})(?:-(\d+))?$/);
92
+ if (!match) {
93
+ throw new Error(`unsupported version: ${version}`);
94
+ }
95
+ return {
96
+ parts: (match[1] ?? "").split(".").map(Number),
97
+ build: match[2] ? Number(match[2]) : 0,
98
+ };
99
+ }
100
+
101
+ function compareVersions(a: string, b: string): number {
102
+ const left = parseComparableVersion(a);
103
+ const right = parseComparableVersion(b);
104
+ const width = Math.max(left.parts.length, right.parts.length);
105
+ for (let i = 0; i < width; i += 1) {
106
+ const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0);
107
+ if (diff !== 0) {
108
+ return diff > 0 ? 1 : -1;
109
+ }
110
+ }
111
+ const buildDiff = left.build - right.build;
112
+ if (buildDiff !== 0) {
113
+ return buildDiff > 0 ? 1 : -1;
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ /** True when `candidate` is strictly newer than `current`. */
119
+ export function isVersionOlder(current: string, candidate: string): boolean {
120
+ return compareVersions(current, candidate) < 0;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Manifest parsing + validation (ported from install-cli `check-update.ts`).
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export interface SkillManifestEntry {
128
+ /** Skill version (matches the SKILL.md frontmatter `version`). */
129
+ version: string;
130
+ /** Repo-relative path under `skills/`, e.g. `openclaw/clawchat/SKILL.md`. */
131
+ path: string;
132
+ /** Lowercase hex sha256 of the raw `SKILL.md` bytes. */
133
+ sha256: string;
134
+ /** Byte length of the raw `SKILL.md`. */
135
+ bytes: number;
136
+ }
137
+
138
+ export interface SkillsManifest {
139
+ schema: number;
140
+ skills: Record<string, Record<string, SkillManifestEntry>>;
141
+ }
142
+
143
+ export interface SkillUpdate {
144
+ skillId: string;
145
+ /** Locally installed version, or `null` when the skill is not installed. */
146
+ current: string | null;
147
+ /** Version offered by the official source. */
148
+ latest: string;
149
+ hasUpdate: boolean;
150
+ path: string;
151
+ sha256: string;
152
+ bytes: number;
153
+ }
154
+
155
+ export interface CheckSkillUpdateOutcome {
156
+ ref: string;
157
+ results: SkillUpdate[];
158
+ hasUpdate: boolean;
159
+ }
160
+
161
+ function skillsBase(base: string | undefined, ref: string): string {
162
+ return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/skills`;
163
+ }
164
+
165
+ export function manifestUrl(ref: string = DEFAULT_SKILLS_REF, base?: string): string {
166
+ return `${skillsBase(base, ref)}/manifest.json`;
167
+ }
168
+
169
+ export function skillContentUrl(
170
+ entryPath: string,
171
+ ref: string = DEFAULT_SKILLS_REF,
172
+ base?: string,
173
+ ): string {
174
+ return `${skillsBase(base, ref)}/${entryPath.replace(/^\/+/, "")}`;
175
+ }
176
+
177
+ function asEntry(value: unknown, where: string): SkillManifestEntry {
178
+ if (!value || typeof value !== "object") {
179
+ throw new Error(`skills manifest entry ${where} is not an object`);
180
+ }
181
+ const v = value as Record<string, unknown>;
182
+ const version = typeof v.version === "string" ? v.version.trim() : "";
183
+ const entryPath = typeof v.path === "string" ? v.path.trim() : "";
184
+ const sha256 = typeof v.sha256 === "string" ? v.sha256.trim().toLowerCase() : "";
185
+ const bytes = typeof v.bytes === "number" ? v.bytes : NaN;
186
+ if (!version) throw new Error(`skills manifest entry ${where} missing version`);
187
+ if (!entryPath) throw new Error(`skills manifest entry ${where} missing path`);
188
+ if (!/^[0-9a-f]{64}$/.test(sha256)) {
189
+ throw new Error(`skills manifest entry ${where} has invalid sha256`);
190
+ }
191
+ if (!Number.isInteger(bytes) || bytes < 0) {
192
+ throw new Error(`skills manifest entry ${where} has invalid bytes`);
193
+ }
194
+ return { version, path: entryPath, sha256, bytes };
195
+ }
196
+
197
+ /** Parse and validate raw manifest text. */
198
+ export function parseSkillsManifest(text: string): SkillsManifest {
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(text);
202
+ } catch (err) {
203
+ throw new Error(`failed to parse skills manifest: ${(err as Error).message}`);
204
+ }
205
+ if (!parsed || typeof parsed !== "object") {
206
+ throw new Error("skills manifest must be a JSON object");
207
+ }
208
+ const data = parsed as Record<string, unknown>;
209
+ if (data.schema !== 1) {
210
+ throw new Error(`unsupported skills manifest schema: ${JSON.stringify(data.schema)}`);
211
+ }
212
+ if (!data.skills || typeof data.skills !== "object") {
213
+ throw new Error("skills manifest missing `skills`");
214
+ }
215
+ const skills: SkillsManifest["skills"] = {};
216
+ for (const [target, entries] of Object.entries(data.skills as Record<string, unknown>)) {
217
+ if (!entries || typeof entries !== "object") {
218
+ throw new Error(`skills manifest target ${target} is not an object`);
219
+ }
220
+ skills[target] = {};
221
+ for (const [skillId, entry] of Object.entries(entries as Record<string, unknown>)) {
222
+ skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
223
+ }
224
+ }
225
+ return { schema: 1, skills };
226
+ }
227
+
228
+ async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
229
+ let response: FetchResponseLike;
230
+ try {
231
+ response = await fetchFn(url, { method: "GET" });
232
+ } catch (err) {
233
+ throw new Error(`fetch ${url} failed: ${(err as Error).message}`);
234
+ }
235
+ if (!response.ok) {
236
+ throw new Error(`fetch ${url} returned status ${response.status}`);
237
+ }
238
+ return response.text();
239
+ }
240
+
241
+ export interface CheckSkillUpdateOptions {
242
+ /** Locally installed versions: `{ "<skillId>": "<version>" }`. */
243
+ current: Record<string, string>;
244
+ ref?: string;
245
+ base?: string;
246
+ fetchFn: FetchLike;
247
+ }
248
+
249
+ /**
250
+ * Read the official manifest for the `openclaw` target and compare each skill's
251
+ * offered version against the locally installed `current` map. A skill missing
252
+ * from `current` is reported as `hasUpdate: true`.
253
+ */
254
+ export async function checkSkillUpdate(
255
+ options: CheckSkillUpdateOptions,
256
+ ): Promise<CheckSkillUpdateOutcome> {
257
+ const ref = options.ref ?? DEFAULT_SKILLS_REF;
258
+ const text = await fetchText(manifestUrl(ref, options.base), options.fetchFn);
259
+ const manifest = parseSkillsManifest(text);
260
+ const targetSkills = manifest.skills[SKILL_TARGET];
261
+ if (!targetSkills) {
262
+ throw new Error(`skills manifest has no entry for target ${SKILL_TARGET}`);
263
+ }
264
+
265
+ const results: SkillUpdate[] = [];
266
+ for (const [skillId, entry] of Object.entries(targetSkills)) {
267
+ const current = options.current[skillId] ?? null;
268
+ const hasUpdate = current === null ? true : isVersionOlder(current, entry.version);
269
+ results.push({
270
+ skillId,
271
+ current,
272
+ latest: entry.version,
273
+ hasUpdate,
274
+ path: entry.path,
275
+ sha256: entry.sha256,
276
+ bytes: entry.bytes,
277
+ });
278
+ }
279
+ return { ref, results, hasUpdate: results.some((r) => r.hasUpdate) };
280
+ }
281
+
282
+ /**
283
+ * Download one skill markdown file and integrity-check it against the manifest
284
+ * entry (size cap + exact sha256). Returns the raw markdown text on success.
285
+ */
286
+ export async function fetchSkillMarkdown(
287
+ entry: Pick<SkillManifestEntry, "path" | "sha256" | "bytes">,
288
+ options: { ref?: string; base?: string; fetchFn: FetchLike },
289
+ ): Promise<string> {
290
+ const ref = options.ref ?? DEFAULT_SKILLS_REF;
291
+ const text = await fetchText(skillContentUrl(entry.path, ref, options.base), options.fetchFn);
292
+ const buf = Buffer.from(text, "utf8");
293
+ if (buf.length > MAX_SKILL_BYTES) {
294
+ throw new Error(`skill ${entry.path} is ${buf.length} bytes, over the ${MAX_SKILL_BYTES} cap`);
295
+ }
296
+ const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
297
+ if (sha256 !== entry.sha256.toLowerCase()) {
298
+ throw new Error(`skill ${entry.path} sha256 mismatch: got ${sha256}, expected ${entry.sha256}`);
299
+ }
300
+ return text;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Local skill version resolution (frontmatter `version:`).
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /** Extract the `version` field from a SKILL.md YAML frontmatter block. */
308
+ export function parseSkillFrontmatterVersion(markdown: string): string | null {
309
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
310
+ if (!match) return null;
311
+ const frontmatter = match[1] ?? "";
312
+ const versionLine = frontmatter
313
+ .split(/\r?\n/)
314
+ .map((line) => line.match(/^version:\s*(.+?)\s*$/))
315
+ .find((m): m is RegExpMatchArray => m !== null);
316
+ if (!versionLine) return null;
317
+ const raw = (versionLine[1] ?? "").trim().replace(/^["']|["']$/g, "");
318
+ return raw || null;
319
+ }
320
+
321
+ /** Read one skill's frontmatter `version` from `<dir>/<skillId>/SKILL.md`. */
322
+ async function readSingleSkillVersion(dir: string, skillId: string): Promise<string | null> {
323
+ try {
324
+ const md = await fs.readFile(path.join(dir, skillId, "SKILL.md"), "utf8");
325
+ return parseSkillFrontmatterVersion(md);
326
+ } catch {
327
+ return null; // missing / unreadable
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Read the installed version of each requested skill from a SINGLE directory
333
+ * (`<skillsDir>/<skillId>/SKILL.md`). Missing files / missing frontmatter
334
+ * version are simply omitted (treated as "not installed" → update available).
335
+ */
336
+ export async function readLocalSkillVersions(
337
+ skillsDir: string,
338
+ skillIds: readonly string[] = OPENCLAW_SKILL_IDS,
339
+ ): Promise<Record<string, string>> {
340
+ const versions: Record<string, string> = {};
341
+ for (const skillId of skillIds) {
342
+ const version = await readSingleSkillVersion(skillsDir, skillId);
343
+ if (version) versions[skillId] = version;
344
+ }
345
+ return versions;
346
+ }
347
+
348
+ /**
349
+ * Read the EFFECTIVE installed version of each skill, mirroring the host's
350
+ * managed-over-bundled precedence (`src/skills/loading/workspace.ts:1220-1239`,
351
+ * managed=3 > bundled=2): use the managed copy's frontmatter `version` when
352
+ * `<managedDir>/<id>/SKILL.md` exists, otherwise fall back to the bundled
353
+ * copy's. A skill present in neither is omitted (→ update available).
354
+ */
355
+ export async function readEffectiveSkillVersions(
356
+ managedDir: string,
357
+ bundledDir: string | null,
358
+ skillIds: readonly string[] = OPENCLAW_SKILL_IDS,
359
+ ): Promise<Record<string, string>> {
360
+ const versions: Record<string, string> = {};
361
+ for (const skillId of skillIds) {
362
+ const managed = await readSingleSkillVersion(managedDir, skillId);
363
+ if (managed) {
364
+ versions[skillId] = managed;
365
+ continue;
366
+ }
367
+ if (bundledDir) {
368
+ const bundled = await readSingleSkillVersion(bundledDir, skillId);
369
+ if (bundled) versions[skillId] = bundled;
370
+ }
371
+ }
372
+ return versions;
373
+ }
374
+
375
+ /**
376
+ * Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
377
+ * `OPENCLAW_STATE_DIR` if set; else the dirname of `OPENCLAW_CONFIG_PATH` if
378
+ * set; else `~/.openclaw`.
379
+ */
380
+ export function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
381
+ const stateDir = env.OPENCLAW_STATE_DIR?.trim();
382
+ if (stateDir) return stateDir;
383
+ const configPath = env.OPENCLAW_CONFIG_PATH?.trim();
384
+ if (configPath) return path.dirname(configPath);
385
+ return path.join(os.homedir(), ".openclaw");
386
+ }
387
+
388
+ /**
389
+ * Resolve the OpenClaw-managed skills directory — THE write target for an
390
+ * applied skill update: `<stateDir>/skills`.
391
+ *
392
+ * WHY THIS DIR (and NOT the plugin's bundled `./skills`):
393
+ * - It is chokidar-watched by the host (`src/skills/runtime/refresh.ts:107`),
394
+ * so an atomic write triggers the snapshot rebuild on the next turn — the
395
+ * same activation proven in plan §7.2, without a restart or `config reload`.
396
+ * - Managed skills load at HIGHER precedence than plugin-bundled ones
397
+ * (managed=3 > bundled=2, `src/skills/loading/workspace.ts:1220-1239`), so a
398
+ * managed `SKILL.md` OVERRIDES the bundled copy of the same skill id.
399
+ * - It is writable and persistent across npm installs, whereas the bundled
400
+ * `./skills` lives under `node_modules` → can be read-only and is clobbered
401
+ * on `openclaw plugins update`. This mirrors the Hermes adapter's
402
+ * `$HERMES_HOME/clawchat-skills` design.
403
+ */
404
+ export function resolveManagedSkillsDir(env: NodeJS.ProcessEnv = process.env): string {
405
+ return path.join(resolveStateDir(env), "skills");
406
+ }
407
+
408
+ /**
409
+ * Resolve the plugin's bundled skills directory.
410
+ *
411
+ * READ-SOURCE ONLY: this dir (the plugin's own `./skills`, declared
412
+ * `"skills": ["./skills"]` in `openclaw.plugin.json`) is used solely to read
413
+ * the first-boot / fallback "current installed version" when no managed copy
414
+ * exists yet. It is NEVER a write target — applied updates go to the
415
+ * OpenClaw-managed dir ({@link resolveManagedSkillsDir}).
416
+ *
417
+ * Resolution walks up from this module's location looking for a directory that
418
+ * contains both `openclaw.plugin.json` and a `skills/` subdir — this works for
419
+ * both the built layout (`dist/src/skill-update.js`) and the source layout
420
+ * (`src/skill-update.ts`).
421
+ */
422
+ export function resolveBundledSkillsDir(fromUrl: string = import.meta.url): string {
423
+ let dir = path.dirname(fileURLToPath(fromUrl));
424
+ for (let i = 0; i < 8; i += 1) {
425
+ if (existsSync(path.join(dir, "openclaw.plugin.json")) && existsSync(path.join(dir, "skills"))) {
426
+ return path.join(dir, "skills");
427
+ }
428
+ const parent = path.dirname(dir);
429
+ if (parent === dir) break;
430
+ dir = parent;
431
+ }
432
+ throw new Error("could not resolve bundled skills dir (no openclaw.plugin.json + skills/ ancestor)");
433
+ }
434
+
435
+ /**
436
+ * Atomically write `<skillsDir>/<skillId>/SKILL.md` with `content`. `mkdir -p`
437
+ * the `<skillsDir>/<skillId>/` dir first (the managed dir may not exist yet).
438
+ *
439
+ * HARD INVARIANT (plan §4 #5): write to a temp file in the SAME directory then
440
+ * `rename` into place. NEVER delete-then-write — a transient "file missing"
441
+ * window would make OpenClaw drop the skill registration and demand a reload.
442
+ */
443
+ export async function atomicWriteSkill(
444
+ skillsDir: string,
445
+ skillId: string,
446
+ content: string,
447
+ ): Promise<void> {
448
+ const dir = path.join(skillsDir, skillId);
449
+ await fs.mkdir(dir, { recursive: true });
450
+ const target = path.join(dir, "SKILL.md");
451
+ const tmp = path.join(dir, `.SKILL.md.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
452
+ await fs.writeFile(tmp, content, "utf8");
453
+ try {
454
+ await fs.rename(tmp, target);
455
+ } catch (err) {
456
+ await fs.rm(tmp, { force: true }).catch(() => {});
457
+ throw err;
458
+ }
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Conservative consent parsing.
463
+ // ---------------------------------------------------------------------------
464
+
465
+ export type ConsentVerdict = "affirm" | "deny" | "ambiguous";
466
+
467
+ const AFFIRM_TOKENS = new Set([
468
+ "更新",
469
+ "更新吧",
470
+ "确认",
471
+ "确认更新",
472
+ "同意",
473
+ "同意更新",
474
+ "好",
475
+ "好的",
476
+ "好啊",
477
+ "可以",
478
+ "行",
479
+ "yes",
480
+ "y",
481
+ "ok",
482
+ "okay",
483
+ "update",
484
+ ]);
485
+
486
+ const DENY_TOKENS = new Set([
487
+ "取消",
488
+ "取消更新",
489
+ "不更新",
490
+ "不",
491
+ "不要",
492
+ "不用",
493
+ "否",
494
+ "拒绝",
495
+ "算了",
496
+ "no",
497
+ "n",
498
+ "cancel",
499
+ ]);
500
+
501
+ /**
502
+ * CONSERVATIVE consent parse: only a message that, once stripped of whitespace
503
+ * and common punctuation, equals a single standalone consent token counts as
504
+ * affirm/deny. Anything else (a sentence that merely contains "更新", an
505
+ * unrelated reply, an empty string) is `ambiguous` and must NOT be consumed —
506
+ * it flows on to the normal LLM pipeline while the pending record survives
507
+ * until its timeout. This keeps "soft consent" from misfiring on ordinary chat.
508
+ */
509
+ export function parseConsent(text: string): ConsentVerdict {
510
+ const norm = (text ?? "")
511
+ .trim()
512
+ .replace(/[\s。.!?!?,,、…~~·"'「」『』()()【】\[\]]/g, "")
513
+ .toLowerCase();
514
+ if (!norm) return "ambiguous";
515
+ if (AFFIRM_TOKENS.has(norm)) return "affirm";
516
+ if (DENY_TOKENS.has(norm)) return "deny";
517
+ return "ambiguous";
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Pending-consent store (in-memory, single pending record per runtime).
522
+ // ---------------------------------------------------------------------------
523
+
524
+ export interface PendingSkillTarget {
525
+ skillId: string;
526
+ current: string | null;
527
+ target: string;
528
+ path: string;
529
+ sha256: string;
530
+ bytes: number;
531
+ }
532
+
533
+ export interface PendingSkillUpdate {
534
+ ownerUserId: string;
535
+ ref: string;
536
+ base?: string;
537
+ updates: PendingSkillTarget[];
538
+ createdAt: number;
539
+ expiresAt: number;
540
+ }
541
+
542
+ /** Default consent window: a pending ask expires after 30 minutes. */
543
+ export const DEFAULT_CONSENT_TIMEOUT_MS = 30 * 60 * 1000;
544
+
545
+ export class PendingConsentStore {
546
+ private pending: PendingSkillUpdate | null = null;
547
+ constructor(private readonly now: () => number = Date.now) {}
548
+
549
+ set(record: PendingSkillUpdate): void {
550
+ this.pending = record;
551
+ }
552
+
553
+ /** Returns the live pending record, clearing + returning null if expired. */
554
+ get(): PendingSkillUpdate | null {
555
+ if (!this.pending) return null;
556
+ if (this.now() >= this.pending.expiresAt) {
557
+ this.pending = null;
558
+ return null;
559
+ }
560
+ return this.pending;
561
+ }
562
+
563
+ clear(): void {
564
+ this.pending = null;
565
+ }
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Orchestration: notify.signal check → ask owner; owner reply → apply.
570
+ // ---------------------------------------------------------------------------
571
+
572
+ export interface SkillUpdateLog {
573
+ info?: (m: string) => void;
574
+ error?: (m: string) => void;
575
+ }
576
+
577
+ export interface RunSkillUpdateCheckOptions {
578
+ ownerUserId: string;
579
+ /** Locally installed versions, `{ "<skillId>": "<version>" }`. */
580
+ localVersions: Record<string, string>;
581
+ fetchFn: FetchLike;
582
+ ref?: string;
583
+ base?: string;
584
+ store: PendingConsentStore;
585
+ sendOwnerMessage: (text: string) => Promise<void>;
586
+ now?: () => number;
587
+ timeoutMs?: number;
588
+ log?: SkillUpdateLog;
589
+ }
590
+
591
+ function describeUpdate(u: PendingSkillTarget): string {
592
+ return `「${u.skillId}」v${u.current ?? "无"} → v${u.target}`;
593
+ }
594
+
595
+ /**
596
+ * Step ③–⑤ (plan §2): on `clawchat.skill.update.check`, check the official
597
+ * source; if any skill has a newer version, message the owner asking for
598
+ * consent and record a pending consent entry. No-update → silent. Returns the
599
+ * recorded pending entry, or `null` when there was nothing to ask.
600
+ */
601
+ export async function runSkillUpdateCheck(
602
+ options: RunSkillUpdateCheckOptions,
603
+ ): Promise<PendingSkillUpdate | null> {
604
+ const ownerUserId = options.ownerUserId.trim();
605
+ if (!ownerUserId) {
606
+ options.log?.error?.("clawchat skill-update check: no owner user id; cannot ask for consent");
607
+ return null;
608
+ }
609
+
610
+ const outcome = await checkSkillUpdate({
611
+ current: options.localVersions,
612
+ ...(options.ref ? { ref: options.ref } : {}),
613
+ ...(options.base ? { base: options.base } : {}),
614
+ fetchFn: options.fetchFn,
615
+ });
616
+
617
+ const updates: PendingSkillTarget[] = outcome.results
618
+ .filter((r) => r.hasUpdate)
619
+ .map((r) => ({
620
+ skillId: r.skillId,
621
+ current: r.current,
622
+ target: r.latest,
623
+ path: r.path,
624
+ sha256: r.sha256,
625
+ bytes: r.bytes,
626
+ }));
627
+
628
+ if (updates.length === 0) {
629
+ options.log?.info?.("clawchat skill-update check: no updates available");
630
+ return null;
631
+ }
632
+
633
+ const now = (options.now ?? Date.now)();
634
+ const record: PendingSkillUpdate = {
635
+ ownerUserId,
636
+ ref: outcome.ref,
637
+ ...(options.base ? { base: options.base } : {}),
638
+ updates,
639
+ createdAt: now,
640
+ expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
641
+ };
642
+ options.store.set(record);
643
+
644
+ const text = `我的技能有更新:${updates.map(describeUpdate).join(";")}。回复「更新」确认,「取消」忽略。`;
645
+ await options.sendOwnerMessage(text);
646
+ options.log?.info?.(
647
+ `clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`,
648
+ );
649
+ return record;
650
+ }
651
+
652
+ export interface HandleOwnerConsentReplyOptions {
653
+ ownerUserId: string;
654
+ senderId: string;
655
+ text: string;
656
+ store: PendingConsentStore;
657
+ fetchFn: FetchLike;
658
+ ref?: string;
659
+ base?: string;
660
+ /**
661
+ * The OpenClaw-managed skills dir to write into — the chokidar-watched,
662
+ * higher-precedence target (see {@link resolveManagedSkillsDir}). NOT the
663
+ * bundled `./skills` dir.
664
+ */
665
+ skillsDir: string;
666
+ sendOwnerMessage: (text: string) => Promise<void>;
667
+ /** Re-read the on-disk version of a skill for idempotency checks. */
668
+ readLocalVersion?: (skillId: string) => Promise<string | null>;
669
+ log?: SkillUpdateLog;
670
+ }
671
+
672
+ /**
673
+ * Step ⑦–⑨ (plan §2): the owner-reply pending-consent gate.
674
+ *
675
+ * Returns `true` when the reply was CONSUMED (affirm → applied + acked, or
676
+ * deny → cleared + acked) and must NOT be forwarded to the LLM. Returns `false`
677
+ * when the reply must flow on to normal handling: no pending record, not from
678
+ * the owner, or an ambiguous reply (pending is kept until timeout).
679
+ */
680
+ export async function handleOwnerConsentReply(
681
+ options: HandleOwnerConsentReplyOptions,
682
+ ): Promise<boolean> {
683
+ const pending = options.store.get();
684
+ if (!pending) return false;
685
+
686
+ const ownerUserId = options.ownerUserId.trim();
687
+ // Only the owner can consent, and only to their own pending ask.
688
+ if (!ownerUserId || options.senderId !== ownerUserId || pending.ownerUserId !== ownerUserId) {
689
+ return false;
690
+ }
691
+
692
+ const verdict = parseConsent(options.text);
693
+ if (verdict === "ambiguous") {
694
+ // Do NOT consume — let the message reach the LLM, keep pending until timeout.
695
+ return false;
696
+ }
697
+
698
+ if (verdict === "deny") {
699
+ options.store.clear();
700
+ await options.sendOwnerMessage("已取消");
701
+ options.log?.info?.("clawchat skill-update: owner declined; pending cleared");
702
+ return true;
703
+ }
704
+
705
+ // verdict === "affirm"
706
+ const readLocalVersion =
707
+ options.readLocalVersion ??
708
+ (async (skillId: string) => {
709
+ try {
710
+ const md = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"), "utf8");
711
+ return parseSkillFrontmatterVersion(md);
712
+ } catch {
713
+ return null;
714
+ }
715
+ });
716
+
717
+ const applied: PendingSkillTarget[] = [];
718
+ try {
719
+ for (const update of pending.updates) {
720
+ // Idempotency: skip a skill already at the target version on disk.
721
+ const onDisk = await readLocalVersion(update.skillId);
722
+ if (onDisk === update.target) {
723
+ applied.push(update);
724
+ continue;
725
+ }
726
+ const content = await fetchSkillMarkdown(
727
+ { path: update.path, sha256: update.sha256, bytes: update.bytes },
728
+ {
729
+ ref: pending.ref,
730
+ ...(pending.base ? { base: pending.base } : options.base ? { base: options.base } : {}),
731
+ fetchFn: options.fetchFn,
732
+ },
733
+ );
734
+ await atomicWriteSkill(options.skillsDir, update.skillId, content);
735
+ applied.push(update);
736
+ options.log?.info?.(
737
+ `clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`,
738
+ );
739
+ }
740
+ } catch (err) {
741
+ // Keep the pending record so the owner can retry with another "更新"; report
742
+ // the failure rather than silently dropping it.
743
+ options.log?.error?.(`clawchat skill-update: apply failed: ${(err as Error).message}`);
744
+ await options.sendOwnerMessage("技能更新失败,请稍后再回复「更新」重试。");
745
+ return true;
746
+ }
747
+
748
+ options.store.clear();
749
+ const summary = applied.map((u) => `「${u.skillId}」v${u.target}`).join("、");
750
+ await options.sendOwnerMessage(`✅ 已更新到 ${summary}`);
751
+ return true;
752
+ }