@clawling/clawchat-plugin-openclaw 2026.7.1 → 2026.7.2-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.
- package/dist/src/runtime.js +28 -9
- package/dist/src/skill-update.js +139 -69
- package/package.json +1 -1
- package/src/runtime.ts +32 -10
- package/src/skill-update.ts +170 -85
package/dist/src/runtime.js
CHANGED
|
@@ -11,7 +11,7 @@ import { RefreshManager } from "./refresh-manager.js";
|
|
|
11
11
|
import { runOpenclawClawlingLogin, } from "./login.runtime.js";
|
|
12
12
|
import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
13
13
|
import { dispatchOpenclawClawlingInbound } from "./inbound.js";
|
|
14
|
-
import { PendingConsentStore, handleOwnerConsentReply,
|
|
14
|
+
import { PendingConsentStore, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
|
|
15
15
|
import { fetchInboundMedia } from "./media-runtime.js";
|
|
16
16
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
|
|
17
17
|
import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
|
|
@@ -1762,6 +1762,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1762
1762
|
// The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
|
|
1763
1763
|
// "current installed version" only — never written (it lives in node_modules
|
|
1764
1764
|
// → may be read-only and is clobbered on `openclaw plugins update`).
|
|
1765
|
+
// Update detection converges on raw sha256 (readLocalSkillState), not the
|
|
1766
|
+
// frontmatter version, so a same-version content fix still applies; a
|
|
1767
|
+
// manifest `removed` id tombstones the LOCAL managed copy (never bundled)
|
|
1768
|
+
// via removeManagedSkill, deleting only ids the official manifest names.
|
|
1765
1769
|
const skillUpdateConsent = new PendingConsentStore();
|
|
1766
1770
|
const managedSkillsDir = resolveManagedSkillsDir();
|
|
1767
1771
|
let bundledSkillsDir = null;
|
|
@@ -1771,14 +1775,29 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1771
1775
|
catch (err) {
|
|
1772
1776
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update: bundled skills dir not resolvable (using managed-only current): ${err instanceof Error ? err.message : String(err)}`);
|
|
1773
1777
|
}
|
|
1774
|
-
|
|
1778
|
+
// A usr_... id is not a valid chat_id — member-backend rejects it with
|
|
1779
|
+
// "invalid conversation id" and msghub never acks the send. Proactive owner
|
|
1780
|
+
// messages must be addressed to the direct conversation recorded at
|
|
1781
|
+
// activation (parity with the hermes adapter's _owner_direct_chat_id).
|
|
1782
|
+
const resolveOwnerDirectConversationId = () => {
|
|
1775
1783
|
const ownerUserId = account.ownerUserId?.trim();
|
|
1776
1784
|
if (!ownerUserId)
|
|
1785
|
+
return null;
|
|
1786
|
+
const conversationId = store
|
|
1787
|
+
?.getActivationConversation?.({ platform: "openclaw", accountId })
|
|
1788
|
+
?.conversationId?.trim();
|
|
1789
|
+
return conversationId || null;
|
|
1790
|
+
};
|
|
1791
|
+
const sendOwnerSkillMessage = async (text) => {
|
|
1792
|
+
const conversationId = resolveOwnerDirectConversationId();
|
|
1793
|
+
if (!conversationId) {
|
|
1794
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`);
|
|
1777
1795
|
return;
|
|
1796
|
+
}
|
|
1778
1797
|
await sendOpenclawClawlingText({
|
|
1779
1798
|
client,
|
|
1780
1799
|
account,
|
|
1781
|
-
to: { chatId:
|
|
1800
|
+
to: { chatId: conversationId, chatType: "direct" },
|
|
1782
1801
|
text,
|
|
1783
1802
|
log,
|
|
1784
1803
|
});
|
|
@@ -1790,10 +1809,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1790
1809
|
return;
|
|
1791
1810
|
}
|
|
1792
1811
|
// Effective current = managed copy if present, else bundled (host precedence).
|
|
1793
|
-
const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
|
|
1794
1812
|
await runSkillUpdateCheck({
|
|
1795
1813
|
ownerUserId,
|
|
1796
|
-
|
|
1814
|
+
readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
|
|
1815
|
+
hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
|
|
1797
1816
|
fetchFn: globalThis.fetch,
|
|
1798
1817
|
store: skillUpdateConsent,
|
|
1799
1818
|
sendOwnerMessage: sendOwnerSkillMessage,
|
|
@@ -2408,16 +2427,16 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2408
2427
|
},
|
|
2409
2428
|
});
|
|
2410
2429
|
const forwardGroupApprovalSlashToOwner = async (turn) => {
|
|
2411
|
-
const
|
|
2412
|
-
if (!
|
|
2413
|
-
log?.error?.(`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=
|
|
2430
|
+
const ownerConversationId = resolveOwnerDirectConversationId();
|
|
2431
|
+
if (!ownerConversationId) {
|
|
2432
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`);
|
|
2414
2433
|
return "skipped";
|
|
2415
2434
|
}
|
|
2416
2435
|
try {
|
|
2417
2436
|
await sendOpenclawClawlingText({
|
|
2418
2437
|
client,
|
|
2419
2438
|
account,
|
|
2420
|
-
to: { chatId:
|
|
2439
|
+
to: { chatId: ownerConversationId, chatType: "direct" },
|
|
2421
2440
|
text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
|
|
2422
2441
|
log,
|
|
2423
2442
|
});
|
package/dist/src/skill-update.js
CHANGED
|
@@ -29,8 +29,12 @@ import { fileURLToPath } from "node:url";
|
|
|
29
29
|
* implementation in `@clawling/clawchat-plugin-install-cli`
|
|
30
30
|
* (`packages/core/src/skills/check-update.ts`). That package is
|
|
31
31
|
* workspace-private, so the logic is duplicated here rather than imported.
|
|
32
|
-
* -
|
|
33
|
-
* (
|
|
32
|
+
* - Update detection converges on raw sha256 comparison: ANY byte difference
|
|
33
|
+
* from the official source (or a missing local file) is an update —
|
|
34
|
+
* upgrade, rollback, or same-version content fix alike. The strict semver
|
|
35
|
+
* compare (`parseComparableVersion` / `isVersionOlder`, still ported from
|
|
36
|
+
* the reference `parseComparableVersion`, `X.Y[.Z[.W]][-buildnum]`) is kept
|
|
37
|
+
* only for consent-prompt display wording, not for the `hasUpdate` decision.
|
|
34
38
|
* - All network IO goes through an injected `FetchLike`; tests never touch the
|
|
35
39
|
* real network.
|
|
36
40
|
*
|
|
@@ -151,7 +155,25 @@ export function parseSkillsManifest(text) {
|
|
|
151
155
|
skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
|
-
|
|
158
|
+
const removed = {};
|
|
159
|
+
if (data.removed !== undefined) {
|
|
160
|
+
if (!data.removed || typeof data.removed !== "object" || Array.isArray(data.removed)) {
|
|
161
|
+
throw new Error("skills manifest `removed` must be an object");
|
|
162
|
+
}
|
|
163
|
+
for (const [target, ids] of Object.entries(data.removed)) {
|
|
164
|
+
if (!Array.isArray(ids) || !ids.every((i) => typeof i === "string" && i.trim())) {
|
|
165
|
+
throw new Error(`skills manifest removed[${target}] must be a list of skill ids`);
|
|
166
|
+
}
|
|
167
|
+
const cleaned = ids.map((i) => i.trim());
|
|
168
|
+
for (const id of cleaned) {
|
|
169
|
+
if (skills[target]?.[id]) {
|
|
170
|
+
throw new Error(`skill ${target}.${id} is in both skills and removed`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
removed[target] = cleaned;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { schema: 1, skills, removed };
|
|
155
177
|
}
|
|
156
178
|
async function fetchText(url, fetchFn) {
|
|
157
179
|
let response;
|
|
@@ -167,9 +189,10 @@ async function fetchText(url, fetchFn) {
|
|
|
167
189
|
return response.text();
|
|
168
190
|
}
|
|
169
191
|
/**
|
|
170
|
-
* Read the official manifest for the `openclaw` target and compare each
|
|
171
|
-
* offered
|
|
172
|
-
*
|
|
192
|
+
* Read the official manifest for the `openclaw` target and compare each
|
|
193
|
+
* skill's offered content (sha256) against its effective local state, read
|
|
194
|
+
* on demand via `readLocalState`. Any sha256 mismatch — or no local state at
|
|
195
|
+
* all — is reported as `hasUpdate: true`.
|
|
173
196
|
*/
|
|
174
197
|
export async function checkSkillUpdate(options) {
|
|
175
198
|
const ref = options.ref ?? DEFAULT_SKILLS_REF;
|
|
@@ -181,11 +204,14 @@ export async function checkSkillUpdate(options) {
|
|
|
181
204
|
}
|
|
182
205
|
const results = [];
|
|
183
206
|
for (const [skillId, entry] of Object.entries(targetSkills)) {
|
|
184
|
-
const
|
|
185
|
-
|
|
207
|
+
const state = await options.readLocalState(skillId);
|
|
208
|
+
// sha convergence: any byte difference from the official source (or a
|
|
209
|
+
// missing local file) is an update — upgrade, rollback, or same-version
|
|
210
|
+
// content fix alike.
|
|
211
|
+
const hasUpdate = !state || state.sha256 !== entry.sha256;
|
|
186
212
|
results.push({
|
|
187
213
|
skillId,
|
|
188
|
-
current,
|
|
214
|
+
current: state?.version ?? null,
|
|
189
215
|
latest: entry.version,
|
|
190
216
|
hasUpdate,
|
|
191
217
|
path: entry.path,
|
|
@@ -193,7 +219,12 @@ export async function checkSkillUpdate(options) {
|
|
|
193
219
|
bytes: entry.bytes,
|
|
194
220
|
});
|
|
195
221
|
}
|
|
196
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
ref,
|
|
224
|
+
results,
|
|
225
|
+
hasUpdate: results.some((r) => r.hasUpdate),
|
|
226
|
+
removedIds: manifest.removed[SKILL_TARGET] ?? [],
|
|
227
|
+
};
|
|
197
228
|
}
|
|
198
229
|
/**
|
|
199
230
|
* Download one skill markdown file and integrity-check it against the manifest
|
|
@@ -230,52 +261,41 @@ export function parseSkillFrontmatterVersion(markdown) {
|
|
|
230
261
|
const raw = (versionLine[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
231
262
|
return raw || null;
|
|
232
263
|
}
|
|
233
|
-
|
|
234
|
-
async function readSingleSkillVersion(dir, skillId) {
|
|
264
|
+
async function readSkillStateFromDir(dir, skillId) {
|
|
235
265
|
try {
|
|
236
|
-
const
|
|
237
|
-
return
|
|
266
|
+
const buf = await fs.readFile(path.join(dir, skillId, "SKILL.md"));
|
|
267
|
+
return {
|
|
268
|
+
version: parseSkillFrontmatterVersion(buf.toString("utf8")),
|
|
269
|
+
sha256: crypto.createHash("sha256").update(buf).digest("hex"),
|
|
270
|
+
};
|
|
238
271
|
}
|
|
239
272
|
catch {
|
|
240
|
-
return null;
|
|
273
|
+
return null;
|
|
241
274
|
}
|
|
242
275
|
}
|
|
243
276
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
277
|
+
* Effective local state of ONE skill, mirroring the host's managed-over-bundled
|
|
278
|
+
* precedence: managed copy if present, else bundled, else null. Called per
|
|
279
|
+
* remote manifest entry so dynamically delivered extra skills are hashed too
|
|
280
|
+
* (a static bundled-id list would misreport extras as never-installed).
|
|
247
281
|
*/
|
|
248
|
-
export async function
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return versions;
|
|
282
|
+
export async function readLocalSkillState(managedDir, bundledDir, skillId) {
|
|
283
|
+
const managed = await readSkillStateFromDir(managedDir, skillId);
|
|
284
|
+
if (managed)
|
|
285
|
+
return managed;
|
|
286
|
+
if (bundledDir)
|
|
287
|
+
return readSkillStateFromDir(bundledDir, skillId);
|
|
288
|
+
return null;
|
|
256
289
|
}
|
|
257
|
-
/**
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
}
|
|
290
|
+
/** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
|
|
291
|
+
export async function managedSkillExists(managedDir, skillId) {
|
|
292
|
+
try {
|
|
293
|
+
await fs.access(path.join(managedDir, skillId, "SKILL.md"));
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return false;
|
|
277
298
|
}
|
|
278
|
-
return versions;
|
|
279
299
|
}
|
|
280
300
|
/**
|
|
281
301
|
* Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
|
|
@@ -359,6 +379,18 @@ export async function atomicWriteSkill(skillsDir, skillId, content) {
|
|
|
359
379
|
throw err;
|
|
360
380
|
}
|
|
361
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
|
|
384
|
+
* (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
|
|
385
|
+
* that drops the registration) and best-effort remove the now-empty dir.
|
|
386
|
+
* Deletion is the ONE legitimate delete of a skill path, and ONLY for ids the
|
|
387
|
+
* official manifest explicitly tombstones. Idempotent.
|
|
388
|
+
*/
|
|
389
|
+
export async function removeManagedSkill(skillsDir, skillId) {
|
|
390
|
+
const dir = path.join(skillsDir, skillId);
|
|
391
|
+
await fs.rm(path.join(dir, "SKILL.md"), { force: true });
|
|
392
|
+
await fs.rmdir(dir).catch(() => { });
|
|
393
|
+
}
|
|
362
394
|
const AFFIRM_TOKENS = new Set([
|
|
363
395
|
"更新",
|
|
364
396
|
"更新吧",
|
|
@@ -438,7 +470,20 @@ export class PendingConsentStore {
|
|
|
438
470
|
}
|
|
439
471
|
}
|
|
440
472
|
function describeUpdate(u) {
|
|
441
|
-
|
|
473
|
+
if (u.current === null)
|
|
474
|
+
return `「${u.skillId}」v无 → v${u.target}`;
|
|
475
|
+
let cmp = -1;
|
|
476
|
+
try {
|
|
477
|
+
cmp = compareVersions(u.current, u.target);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
cmp = -1; // unparsable local version — show as a plain update
|
|
481
|
+
}
|
|
482
|
+
if (cmp === 0)
|
|
483
|
+
return `「${u.skillId}」v${u.target}(内容修订)`;
|
|
484
|
+
if (cmp > 0)
|
|
485
|
+
return `「${u.skillId}」v${u.current} → v${u.target}(回滚)`;
|
|
486
|
+
return `「${u.skillId}」v${u.current} → v${u.target}`;
|
|
442
487
|
}
|
|
443
488
|
/**
|
|
444
489
|
* Step ③–⑤ (plan §2): on `clawchat.skill.update.check`, check the official
|
|
@@ -453,23 +498,25 @@ export async function runSkillUpdateCheck(options) {
|
|
|
453
498
|
return null;
|
|
454
499
|
}
|
|
455
500
|
const outcome = await checkSkillUpdate({
|
|
456
|
-
|
|
501
|
+
readLocalState: options.readLocalState,
|
|
457
502
|
...(options.ref ? { ref: options.ref } : {}),
|
|
458
503
|
...(options.base ? { base: options.base } : {}),
|
|
459
504
|
fetchFn: options.fetchFn,
|
|
460
505
|
});
|
|
461
506
|
const updates = outcome.results
|
|
462
507
|
.filter((r) => r.hasUpdate)
|
|
463
|
-
.map((r) => ({
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
508
|
+
.map((r) => ({ skillId: r.skillId, current: r.current, target: r.latest, path: r.path, sha256: r.sha256, bytes: r.bytes }));
|
|
509
|
+
const removals = [];
|
|
510
|
+
for (const skillId of outcome.removedIds) {
|
|
511
|
+
if (OPENCLAW_SKILL_IDS.includes(skillId)) {
|
|
512
|
+
options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (await options.hasManagedSkill(skillId))
|
|
516
|
+
removals.push({ skillId });
|
|
517
|
+
}
|
|
518
|
+
if (updates.length === 0 && removals.length === 0) {
|
|
519
|
+
options.log?.info?.("clawchat skill-update check: nothing to converge");
|
|
473
520
|
return null;
|
|
474
521
|
}
|
|
475
522
|
const now = (options.now ?? Date.now)();
|
|
@@ -478,13 +525,19 @@ export async function runSkillUpdateCheck(options) {
|
|
|
478
525
|
ref: outcome.ref,
|
|
479
526
|
...(options.base ? { base: options.base } : {}),
|
|
480
527
|
updates,
|
|
528
|
+
removals,
|
|
481
529
|
createdAt: now,
|
|
482
530
|
expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
|
|
483
531
|
};
|
|
484
532
|
options.store.set(record);
|
|
485
|
-
const
|
|
533
|
+
const segments = [];
|
|
534
|
+
if (updates.length > 0)
|
|
535
|
+
segments.push(`我的技能有更新:${updates.map(describeUpdate).join(";")}`);
|
|
536
|
+
if (removals.length > 0)
|
|
537
|
+
segments.push(`以下技能将下线移除:${removals.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
538
|
+
const text = `${segments.join(";")}。回复「更新」确认,「取消」忽略。`;
|
|
486
539
|
await options.sendOwnerMessage(text);
|
|
487
|
-
options.log?.info?.(`clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`);
|
|
540
|
+
options.log?.info?.(`clawchat skill-update check: asked owner for consent on updates=[${updates.map((u) => u.skillId).join(",")}] removals=[${removals.map((r) => r.skillId).join(",")}]`);
|
|
488
541
|
return record;
|
|
489
542
|
}
|
|
490
543
|
/**
|
|
@@ -516,22 +569,23 @@ export async function handleOwnerConsentReply(options) {
|
|
|
516
569
|
return true;
|
|
517
570
|
}
|
|
518
571
|
// verdict === "affirm"
|
|
519
|
-
const
|
|
572
|
+
const readLocalSha = options.readLocalSha ??
|
|
520
573
|
(async (skillId) => {
|
|
521
574
|
try {
|
|
522
|
-
const
|
|
523
|
-
return
|
|
575
|
+
const buf = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"));
|
|
576
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
524
577
|
}
|
|
525
578
|
catch {
|
|
526
579
|
return null;
|
|
527
580
|
}
|
|
528
581
|
});
|
|
529
582
|
const applied = [];
|
|
583
|
+
const removed = [];
|
|
530
584
|
try {
|
|
531
585
|
for (const update of pending.updates) {
|
|
532
|
-
// Idempotency: skip a skill already
|
|
533
|
-
const onDisk = await
|
|
534
|
-
if (onDisk === update.
|
|
586
|
+
// Idempotency: skip a skill whose on-disk bytes already match the target.
|
|
587
|
+
const onDisk = await readLocalSha(update.skillId);
|
|
588
|
+
if (onDisk === update.sha256) {
|
|
535
589
|
applied.push(update);
|
|
536
590
|
continue;
|
|
537
591
|
}
|
|
@@ -544,6 +598,18 @@ export async function handleOwnerConsentReply(options) {
|
|
|
544
598
|
applied.push(update);
|
|
545
599
|
options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
|
|
546
600
|
}
|
|
601
|
+
for (const removal of pending.removals) {
|
|
602
|
+
// Defensive re-guard: bundled skills must never be deleted, even though
|
|
603
|
+
// runSkillUpdateCheck already filters them out of removal candidates.
|
|
604
|
+
// Unreachable in the normal flow; kept as a cheap safety net.
|
|
605
|
+
if (OPENCLAW_SKILL_IDS.includes(removal.skillId)) {
|
|
606
|
+
options.log?.info?.(`clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
await removeManagedSkill(options.skillsDir, removal.skillId);
|
|
610
|
+
removed.push(removal);
|
|
611
|
+
options.log?.info?.(`clawchat skill-update: removed tombstoned skill ${removal.skillId}`);
|
|
612
|
+
}
|
|
547
613
|
}
|
|
548
614
|
catch (err) {
|
|
549
615
|
// Keep the pending record so the owner can retry with another "更新"; report
|
|
@@ -553,7 +619,11 @@ export async function handleOwnerConsentReply(options) {
|
|
|
553
619
|
return true;
|
|
554
620
|
}
|
|
555
621
|
options.store.clear();
|
|
556
|
-
const
|
|
557
|
-
|
|
622
|
+
const segments = [];
|
|
623
|
+
if (applied.length > 0)
|
|
624
|
+
segments.push(`已更新到 ${applied.map((u) => `「${u.skillId}」v${u.target}`).join("、")}`);
|
|
625
|
+
if (removed.length > 0)
|
|
626
|
+
segments.push(`已移除 ${removed.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
627
|
+
await options.sendOwnerMessage(`✅ ${segments.join(";")}`);
|
|
558
628
|
return true;
|
|
559
629
|
}
|
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -38,7 +38,8 @@ import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inboun
|
|
|
38
38
|
import {
|
|
39
39
|
PendingConsentStore,
|
|
40
40
|
handleOwnerConsentReply,
|
|
41
|
-
|
|
41
|
+
managedSkillExists,
|
|
42
|
+
readLocalSkillState,
|
|
42
43
|
resolveBundledSkillsDir,
|
|
43
44
|
resolveManagedSkillsDir,
|
|
44
45
|
runSkillUpdateCheck,
|
|
@@ -2154,6 +2155,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2154
2155
|
// The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
|
|
2155
2156
|
// "current installed version" only — never written (it lives in node_modules
|
|
2156
2157
|
// → may be read-only and is clobbered on `openclaw plugins update`).
|
|
2158
|
+
// Update detection converges on raw sha256 (readLocalSkillState), not the
|
|
2159
|
+
// frontmatter version, so a same-version content fix still applies; a
|
|
2160
|
+
// manifest `removed` id tombstones the LOCAL managed copy (never bundled)
|
|
2161
|
+
// via removeManagedSkill, deleting only ids the official manifest names.
|
|
2157
2162
|
const skillUpdateConsent = new PendingConsentStore();
|
|
2158
2163
|
const managedSkillsDir = resolveManagedSkillsDir();
|
|
2159
2164
|
let bundledSkillsDir: string | null = null;
|
|
@@ -2166,13 +2171,30 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2166
2171
|
}`,
|
|
2167
2172
|
);
|
|
2168
2173
|
}
|
|
2169
|
-
|
|
2174
|
+
// A usr_... id is not a valid chat_id — member-backend rejects it with
|
|
2175
|
+
// "invalid conversation id" and msghub never acks the send. Proactive owner
|
|
2176
|
+
// messages must be addressed to the direct conversation recorded at
|
|
2177
|
+
// activation (parity with the hermes adapter's _owner_direct_chat_id).
|
|
2178
|
+
const resolveOwnerDirectConversationId = (): string | null => {
|
|
2170
2179
|
const ownerUserId = account.ownerUserId?.trim();
|
|
2171
|
-
if (!ownerUserId) return;
|
|
2180
|
+
if (!ownerUserId) return null;
|
|
2181
|
+
const conversationId = store
|
|
2182
|
+
?.getActivationConversation?.({ platform: "openclaw", accountId })
|
|
2183
|
+
?.conversationId?.trim();
|
|
2184
|
+
return conversationId || null;
|
|
2185
|
+
};
|
|
2186
|
+
const sendOwnerSkillMessage = async (text: string): Promise<void> => {
|
|
2187
|
+
const conversationId = resolveOwnerDirectConversationId();
|
|
2188
|
+
if (!conversationId) {
|
|
2189
|
+
log?.info?.(
|
|
2190
|
+
`[${accountId}] clawchat-plugin-openclaw skill-update owner message skipped: no activation conversation recorded`,
|
|
2191
|
+
);
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2172
2194
|
await sendOpenclawClawlingText({
|
|
2173
2195
|
client,
|
|
2174
2196
|
account,
|
|
2175
|
-
to: { chatId:
|
|
2197
|
+
to: { chatId: conversationId, chatType: "direct" },
|
|
2176
2198
|
text,
|
|
2177
2199
|
log,
|
|
2178
2200
|
});
|
|
@@ -2186,10 +2208,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2186
2208
|
return;
|
|
2187
2209
|
}
|
|
2188
2210
|
// Effective current = managed copy if present, else bundled (host precedence).
|
|
2189
|
-
const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
|
|
2190
2211
|
await runSkillUpdateCheck({
|
|
2191
2212
|
ownerUserId,
|
|
2192
|
-
|
|
2213
|
+
readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
|
|
2214
|
+
hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
|
|
2193
2215
|
fetchFn: globalThis.fetch,
|
|
2194
2216
|
store: skillUpdateConsent,
|
|
2195
2217
|
sendOwnerMessage: sendOwnerSkillMessage,
|
|
@@ -2886,10 +2908,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2886
2908
|
});
|
|
2887
2909
|
|
|
2888
2910
|
const forwardGroupApprovalSlashToOwner = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
|
|
2889
|
-
const
|
|
2890
|
-
if (!
|
|
2911
|
+
const ownerConversationId = resolveOwnerDirectConversationId();
|
|
2912
|
+
if (!ownerConversationId) {
|
|
2891
2913
|
log?.error?.(
|
|
2892
|
-
`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=
|
|
2914
|
+
`[${accountId}] clawchat-plugin-openclaw group approval command suppressed reason=missing_owner_conversation chat_id=${turn.peer.id} msg=${turn.messageId}`,
|
|
2893
2915
|
);
|
|
2894
2916
|
return "skipped";
|
|
2895
2917
|
}
|
|
@@ -2897,7 +2919,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2897
2919
|
await sendOpenclawClawlingText({
|
|
2898
2920
|
client,
|
|
2899
2921
|
account,
|
|
2900
|
-
to: { chatId:
|
|
2922
|
+
to: { chatId: ownerConversationId, chatType: "direct" },
|
|
2901
2923
|
text: groupOwnerAttentionText(turn.peer.id, turn.rawBody),
|
|
2902
2924
|
log,
|
|
2903
2925
|
});
|
package/src/skill-update.ts
CHANGED
|
@@ -30,8 +30,12 @@ import { fileURLToPath } from "node:url";
|
|
|
30
30
|
* implementation in `@clawling/clawchat-plugin-install-cli`
|
|
31
31
|
* (`packages/core/src/skills/check-update.ts`). That package is
|
|
32
32
|
* workspace-private, so the logic is duplicated here rather than imported.
|
|
33
|
-
* -
|
|
34
|
-
* (
|
|
33
|
+
* - Update detection converges on raw sha256 comparison: ANY byte difference
|
|
34
|
+
* from the official source (or a missing local file) is an update —
|
|
35
|
+
* upgrade, rollback, or same-version content fix alike. The strict semver
|
|
36
|
+
* compare (`parseComparableVersion` / `isVersionOlder`, still ported from
|
|
37
|
+
* the reference `parseComparableVersion`, `X.Y[.Z[.W]][-buildnum]`) is kept
|
|
38
|
+
* only for consent-prompt display wording, not for the `hasUpdate` decision.
|
|
35
39
|
* - All network IO goes through an injected `FetchLike`; tests never touch the
|
|
36
40
|
* real network.
|
|
37
41
|
*
|
|
@@ -138,6 +142,9 @@ export interface SkillManifestEntry {
|
|
|
138
142
|
export interface SkillsManifest {
|
|
139
143
|
schema: number;
|
|
140
144
|
skills: Record<string, Record<string, SkillManifestEntry>>;
|
|
145
|
+
/** Tombstoned skill ids per target. An adapter deletes its LOCAL copy of an
|
|
146
|
+
* id ONLY when it appears here — absence from `skills` alone never deletes. */
|
|
147
|
+
removed: Record<string, string[]>;
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
export interface SkillUpdate {
|
|
@@ -156,6 +163,8 @@ export interface CheckSkillUpdateOutcome {
|
|
|
156
163
|
ref: string;
|
|
157
164
|
results: SkillUpdate[];
|
|
158
165
|
hasUpdate: boolean;
|
|
166
|
+
/** Tombstoned skill ids for this target, straight from `manifest.removed`. */
|
|
167
|
+
removedIds: string[];
|
|
159
168
|
}
|
|
160
169
|
|
|
161
170
|
function skillsBase(base: string | undefined, ref: string): string {
|
|
@@ -222,7 +231,25 @@ export function parseSkillsManifest(text: string): SkillsManifest {
|
|
|
222
231
|
skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
|
|
223
232
|
}
|
|
224
233
|
}
|
|
225
|
-
|
|
234
|
+
const removed: SkillsManifest["removed"] = {};
|
|
235
|
+
if (data.removed !== undefined) {
|
|
236
|
+
if (!data.removed || typeof data.removed !== "object" || Array.isArray(data.removed)) {
|
|
237
|
+
throw new Error("skills manifest `removed` must be an object");
|
|
238
|
+
}
|
|
239
|
+
for (const [target, ids] of Object.entries(data.removed as Record<string, unknown>)) {
|
|
240
|
+
if (!Array.isArray(ids) || !ids.every((i) => typeof i === "string" && (i as string).trim())) {
|
|
241
|
+
throw new Error(`skills manifest removed[${target}] must be a list of skill ids`);
|
|
242
|
+
}
|
|
243
|
+
const cleaned = ids.map((i) => (i as string).trim());
|
|
244
|
+
for (const id of cleaned) {
|
|
245
|
+
if (skills[target]?.[id]) {
|
|
246
|
+
throw new Error(`skill ${target}.${id} is in both skills and removed`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
removed[target] = cleaned;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { schema: 1, skills, removed };
|
|
226
253
|
}
|
|
227
254
|
|
|
228
255
|
async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
|
|
@@ -239,17 +266,22 @@ async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
|
|
|
239
266
|
}
|
|
240
267
|
|
|
241
268
|
export interface CheckSkillUpdateOptions {
|
|
242
|
-
/**
|
|
243
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Effective local state of one skill (managed-over-bundled), or null when
|
|
271
|
+
* not installed. Called once per remote manifest entry — remote-driven, so
|
|
272
|
+
* dynamically delivered extras are covered too.
|
|
273
|
+
*/
|
|
274
|
+
readLocalState: (skillId: string) => Promise<LocalSkillState | null>;
|
|
244
275
|
ref?: string;
|
|
245
276
|
base?: string;
|
|
246
277
|
fetchFn: FetchLike;
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
/**
|
|
250
|
-
* Read the official manifest for the `openclaw` target and compare each
|
|
251
|
-
* offered
|
|
252
|
-
*
|
|
281
|
+
* Read the official manifest for the `openclaw` target and compare each
|
|
282
|
+
* skill's offered content (sha256) against its effective local state, read
|
|
283
|
+
* on demand via `readLocalState`. Any sha256 mismatch — or no local state at
|
|
284
|
+
* all — is reported as `hasUpdate: true`.
|
|
253
285
|
*/
|
|
254
286
|
export async function checkSkillUpdate(
|
|
255
287
|
options: CheckSkillUpdateOptions,
|
|
@@ -264,11 +296,14 @@ export async function checkSkillUpdate(
|
|
|
264
296
|
|
|
265
297
|
const results: SkillUpdate[] = [];
|
|
266
298
|
for (const [skillId, entry] of Object.entries(targetSkills)) {
|
|
267
|
-
const
|
|
268
|
-
|
|
299
|
+
const state = await options.readLocalState(skillId);
|
|
300
|
+
// sha convergence: any byte difference from the official source (or a
|
|
301
|
+
// missing local file) is an update — upgrade, rollback, or same-version
|
|
302
|
+
// content fix alike.
|
|
303
|
+
const hasUpdate = !state || state.sha256 !== entry.sha256;
|
|
269
304
|
results.push({
|
|
270
305
|
skillId,
|
|
271
|
-
current,
|
|
306
|
+
current: state?.version ?? null,
|
|
272
307
|
latest: entry.version,
|
|
273
308
|
hasUpdate,
|
|
274
309
|
path: entry.path,
|
|
@@ -276,7 +311,12 @@ export async function checkSkillUpdate(
|
|
|
276
311
|
bytes: entry.bytes,
|
|
277
312
|
});
|
|
278
313
|
}
|
|
279
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
ref,
|
|
316
|
+
results,
|
|
317
|
+
hasUpdate: results.some((r) => r.hasUpdate),
|
|
318
|
+
removedIds: manifest.removed[SKILL_TARGET] ?? [],
|
|
319
|
+
};
|
|
280
320
|
}
|
|
281
321
|
|
|
282
322
|
/**
|
|
@@ -318,58 +358,50 @@ export function parseSkillFrontmatterVersion(markdown: string): string | null {
|
|
|
318
358
|
return raw || null;
|
|
319
359
|
}
|
|
320
360
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} catch {
|
|
327
|
-
return null; // missing / unreadable
|
|
328
|
-
}
|
|
361
|
+
export interface LocalSkillState {
|
|
362
|
+
/** Frontmatter `version` of the effective file, or null when unparsable. */
|
|
363
|
+
version: string | null;
|
|
364
|
+
/** Lowercase hex sha256 of the effective file's raw bytes. */
|
|
365
|
+
sha256: string;
|
|
329
366
|
}
|
|
330
367
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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;
|
|
368
|
+
async function readSkillStateFromDir(dir: string, skillId: string): Promise<LocalSkillState | null> {
|
|
369
|
+
try {
|
|
370
|
+
const buf = await fs.readFile(path.join(dir, skillId, "SKILL.md"));
|
|
371
|
+
return {
|
|
372
|
+
version: parseSkillFrontmatterVersion(buf.toString("utf8")),
|
|
373
|
+
sha256: crypto.createHash("sha256").update(buf).digest("hex"),
|
|
374
|
+
};
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
344
377
|
}
|
|
345
|
-
return versions;
|
|
346
378
|
}
|
|
347
379
|
|
|
348
380
|
/**
|
|
349
|
-
*
|
|
350
|
-
* managed
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* copy's. A skill present in neither is omitted (→ update available).
|
|
381
|
+
* Effective local state of ONE skill, mirroring the host's managed-over-bundled
|
|
382
|
+
* precedence: managed copy if present, else bundled, else null. Called per
|
|
383
|
+
* remote manifest entry so dynamically delivered extra skills are hashed too
|
|
384
|
+
* (a static bundled-id list would misreport extras as never-installed).
|
|
354
385
|
*/
|
|
355
|
-
export async function
|
|
386
|
+
export async function readLocalSkillState(
|
|
356
387
|
managedDir: string,
|
|
357
388
|
bundledDir: string | null,
|
|
358
|
-
|
|
359
|
-
): Promise<
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
389
|
+
skillId: string,
|
|
390
|
+
): Promise<LocalSkillState | null> {
|
|
391
|
+
const managed = await readSkillStateFromDir(managedDir, skillId);
|
|
392
|
+
if (managed) return managed;
|
|
393
|
+
if (bundledDir) return readSkillStateFromDir(bundledDir, skillId);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
|
|
398
|
+
export async function managedSkillExists(managedDir: string, skillId: string): Promise<boolean> {
|
|
399
|
+
try {
|
|
400
|
+
await fs.access(path.join(managedDir, skillId, "SKILL.md"));
|
|
401
|
+
return true;
|
|
402
|
+
} catch {
|
|
403
|
+
return false;
|
|
371
404
|
}
|
|
372
|
-
return versions;
|
|
373
405
|
}
|
|
374
406
|
|
|
375
407
|
/**
|
|
@@ -458,6 +490,19 @@ export async function atomicWriteSkill(
|
|
|
458
490
|
}
|
|
459
491
|
}
|
|
460
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
|
|
495
|
+
* (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
|
|
496
|
+
* that drops the registration) and best-effort remove the now-empty dir.
|
|
497
|
+
* Deletion is the ONE legitimate delete of a skill path, and ONLY for ids the
|
|
498
|
+
* official manifest explicitly tombstones. Idempotent.
|
|
499
|
+
*/
|
|
500
|
+
export async function removeManagedSkill(skillsDir: string, skillId: string): Promise<void> {
|
|
501
|
+
const dir = path.join(skillsDir, skillId);
|
|
502
|
+
await fs.rm(path.join(dir, "SKILL.md"), { force: true });
|
|
503
|
+
await fs.rmdir(dir).catch(() => {});
|
|
504
|
+
}
|
|
505
|
+
|
|
461
506
|
// ---------------------------------------------------------------------------
|
|
462
507
|
// Conservative consent parsing.
|
|
463
508
|
// ---------------------------------------------------------------------------
|
|
@@ -530,11 +575,16 @@ export interface PendingSkillTarget {
|
|
|
530
575
|
bytes: number;
|
|
531
576
|
}
|
|
532
577
|
|
|
578
|
+
export interface PendingSkillRemoval {
|
|
579
|
+
skillId: string;
|
|
580
|
+
}
|
|
581
|
+
|
|
533
582
|
export interface PendingSkillUpdate {
|
|
534
583
|
ownerUserId: string;
|
|
535
584
|
ref: string;
|
|
536
585
|
base?: string;
|
|
537
586
|
updates: PendingSkillTarget[];
|
|
587
|
+
removals: PendingSkillRemoval[];
|
|
538
588
|
createdAt: number;
|
|
539
589
|
expiresAt: number;
|
|
540
590
|
}
|
|
@@ -576,8 +626,13 @@ export interface SkillUpdateLog {
|
|
|
576
626
|
|
|
577
627
|
export interface RunSkillUpdateCheckOptions {
|
|
578
628
|
ownerUserId: string;
|
|
579
|
-
/**
|
|
580
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Effective local state of one skill (managed-over-bundled), or null when
|
|
631
|
+
* not installed. See {@link CheckSkillUpdateOptions.readLocalState}.
|
|
632
|
+
*/
|
|
633
|
+
readLocalState: (skillId: string) => Promise<LocalSkillState | null>;
|
|
634
|
+
/** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
|
|
635
|
+
hasManagedSkill: (skillId: string) => Promise<boolean>;
|
|
581
636
|
fetchFn: FetchLike;
|
|
582
637
|
ref?: string;
|
|
583
638
|
base?: string;
|
|
@@ -589,7 +644,16 @@ export interface RunSkillUpdateCheckOptions {
|
|
|
589
644
|
}
|
|
590
645
|
|
|
591
646
|
function describeUpdate(u: PendingSkillTarget): string {
|
|
592
|
-
return `「${u.skillId}」v
|
|
647
|
+
if (u.current === null) return `「${u.skillId}」v无 → v${u.target}`;
|
|
648
|
+
let cmp = -1;
|
|
649
|
+
try {
|
|
650
|
+
cmp = compareVersions(u.current, u.target);
|
|
651
|
+
} catch {
|
|
652
|
+
cmp = -1; // unparsable local version — show as a plain update
|
|
653
|
+
}
|
|
654
|
+
if (cmp === 0) return `「${u.skillId}」v${u.target}(内容修订)`;
|
|
655
|
+
if (cmp > 0) return `「${u.skillId}」v${u.current} → v${u.target}(回滚)`;
|
|
656
|
+
return `「${u.skillId}」v${u.current} → v${u.target}`;
|
|
593
657
|
}
|
|
594
658
|
|
|
595
659
|
/**
|
|
@@ -608,7 +672,7 @@ export async function runSkillUpdateCheck(
|
|
|
608
672
|
}
|
|
609
673
|
|
|
610
674
|
const outcome = await checkSkillUpdate({
|
|
611
|
-
|
|
675
|
+
readLocalState: options.readLocalState,
|
|
612
676
|
...(options.ref ? { ref: options.ref } : {}),
|
|
613
677
|
...(options.base ? { base: options.base } : {}),
|
|
614
678
|
fetchFn: options.fetchFn,
|
|
@@ -616,17 +680,19 @@ export async function runSkillUpdateCheck(
|
|
|
616
680
|
|
|
617
681
|
const updates: PendingSkillTarget[] = outcome.results
|
|
618
682
|
.filter((r) => r.hasUpdate)
|
|
619
|
-
.map((r) => ({
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
683
|
+
.map((r) => ({ skillId: r.skillId, current: r.current, target: r.latest, path: r.path, sha256: r.sha256, bytes: r.bytes }));
|
|
684
|
+
|
|
685
|
+
const removals: PendingSkillRemoval[] = [];
|
|
686
|
+
for (const skillId of outcome.removedIds) {
|
|
687
|
+
if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(skillId)) {
|
|
688
|
+
options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (await options.hasManagedSkill(skillId)) removals.push({ skillId });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (updates.length === 0 && removals.length === 0) {
|
|
695
|
+
options.log?.info?.("clawchat skill-update check: nothing to converge");
|
|
630
696
|
return null;
|
|
631
697
|
}
|
|
632
698
|
|
|
@@ -636,15 +702,19 @@ export async function runSkillUpdateCheck(
|
|
|
636
702
|
ref: outcome.ref,
|
|
637
703
|
...(options.base ? { base: options.base } : {}),
|
|
638
704
|
updates,
|
|
705
|
+
removals,
|
|
639
706
|
createdAt: now,
|
|
640
707
|
expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
|
|
641
708
|
};
|
|
642
709
|
options.store.set(record);
|
|
643
710
|
|
|
644
|
-
const
|
|
711
|
+
const segments: string[] = [];
|
|
712
|
+
if (updates.length > 0) segments.push(`我的技能有更新:${updates.map(describeUpdate).join(";")}`);
|
|
713
|
+
if (removals.length > 0) segments.push(`以下技能将下线移除:${removals.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
714
|
+
const text = `${segments.join(";")}。回复「更新」确认,「取消」忽略。`;
|
|
645
715
|
await options.sendOwnerMessage(text);
|
|
646
716
|
options.log?.info?.(
|
|
647
|
-
`clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`,
|
|
717
|
+
`clawchat skill-update check: asked owner for consent on updates=[${updates.map((u) => u.skillId).join(",")}] removals=[${removals.map((r) => r.skillId).join(",")}]`,
|
|
648
718
|
);
|
|
649
719
|
return record;
|
|
650
720
|
}
|
|
@@ -664,8 +734,8 @@ export interface HandleOwnerConsentReplyOptions {
|
|
|
664
734
|
*/
|
|
665
735
|
skillsDir: string;
|
|
666
736
|
sendOwnerMessage: (text: string) => Promise<void>;
|
|
667
|
-
/** Re-read the on-disk
|
|
668
|
-
|
|
737
|
+
/** Re-read the on-disk sha of a managed skill for idempotency checks. */
|
|
738
|
+
readLocalSha?: (skillId: string) => Promise<string | null>;
|
|
669
739
|
log?: SkillUpdateLog;
|
|
670
740
|
}
|
|
671
741
|
|
|
@@ -703,23 +773,24 @@ export async function handleOwnerConsentReply(
|
|
|
703
773
|
}
|
|
704
774
|
|
|
705
775
|
// verdict === "affirm"
|
|
706
|
-
const
|
|
707
|
-
options.
|
|
776
|
+
const readLocalSha =
|
|
777
|
+
options.readLocalSha ??
|
|
708
778
|
(async (skillId: string) => {
|
|
709
779
|
try {
|
|
710
|
-
const
|
|
711
|
-
return
|
|
780
|
+
const buf = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"));
|
|
781
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
712
782
|
} catch {
|
|
713
783
|
return null;
|
|
714
784
|
}
|
|
715
785
|
});
|
|
716
786
|
|
|
717
787
|
const applied: PendingSkillTarget[] = [];
|
|
788
|
+
const removed: PendingSkillRemoval[] = [];
|
|
718
789
|
try {
|
|
719
790
|
for (const update of pending.updates) {
|
|
720
|
-
// Idempotency: skip a skill already
|
|
721
|
-
const onDisk = await
|
|
722
|
-
if (onDisk === update.
|
|
791
|
+
// Idempotency: skip a skill whose on-disk bytes already match the target.
|
|
792
|
+
const onDisk = await readLocalSha(update.skillId);
|
|
793
|
+
if (onDisk === update.sha256) {
|
|
723
794
|
applied.push(update);
|
|
724
795
|
continue;
|
|
725
796
|
}
|
|
@@ -733,9 +804,21 @@ export async function handleOwnerConsentReply(
|
|
|
733
804
|
);
|
|
734
805
|
await atomicWriteSkill(options.skillsDir, update.skillId, content);
|
|
735
806
|
applied.push(update);
|
|
736
|
-
options.log?.info?.(
|
|
737
|
-
|
|
738
|
-
|
|
807
|
+
options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
|
|
808
|
+
}
|
|
809
|
+
for (const removal of pending.removals) {
|
|
810
|
+
// Defensive re-guard: bundled skills must never be deleted, even though
|
|
811
|
+
// runSkillUpdateCheck already filters them out of removal candidates.
|
|
812
|
+
// Unreachable in the normal flow; kept as a cheap safety net.
|
|
813
|
+
if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(removal.skillId)) {
|
|
814
|
+
options.log?.info?.(
|
|
815
|
+
`clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`,
|
|
816
|
+
);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
await removeManagedSkill(options.skillsDir, removal.skillId);
|
|
820
|
+
removed.push(removal);
|
|
821
|
+
options.log?.info?.(`clawchat skill-update: removed tombstoned skill ${removal.skillId}`);
|
|
739
822
|
}
|
|
740
823
|
} catch (err) {
|
|
741
824
|
// Keep the pending record so the owner can retry with another "更新"; report
|
|
@@ -746,7 +829,9 @@ export async function handleOwnerConsentReply(
|
|
|
746
829
|
}
|
|
747
830
|
|
|
748
831
|
options.store.clear();
|
|
749
|
-
const
|
|
750
|
-
|
|
832
|
+
const segments: string[] = [];
|
|
833
|
+
if (applied.length > 0) segments.push(`已更新到 ${applied.map((u) => `「${u.skillId}」v${u.target}`).join("、")}`);
|
|
834
|
+
if (removed.length > 0) segments.push(`已移除 ${removed.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
835
|
+
await options.sendOwnerMessage(`✅ ${segments.join(";")}`);
|
|
751
836
|
return true;
|
|
752
837
|
}
|